diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt index 7379b5ef..4bdc2701 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -1,6 +1,7 @@ package com.bitchat.android.testhook import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.util.Log import com.bitchat.android.favorites.FavoritesPersistenceService @@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.TransferProgressManager +import com.bitchat.android.mesh.CourierDepositTier +import com.bitchat.android.mesh.CourierStore import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.CourierEnvelope import com.bitchat.android.model.RoutedPacket import com.bitchat.android.noise.NoiseSession import com.bitchat.android.protocol.BitchatPacket @@ -23,8 +27,11 @@ import com.bitchat.android.service.MeshForegroundService import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ConversationStorageCipher +import com.bitchat.android.services.MessageRouter import com.bitchat.android.ui.DataManager import com.bitchat.android.ui.PrivateMediaRecipientResolver +import com.bitchat.android.ui.debug.DebugSettingsManager import com.bitchat.android.util.AppConstants import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -69,6 +76,13 @@ object TestHookDriver { "announce" -> announce(context) "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel")) "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) + "router_private_send" -> routerPrivateSend( + context, + intent.requiredString("peer"), + intent.requiredString("content"), + intent.getStringExtra("msg_id") + ) + "router_resume" -> routerResume(context) "dm_recv" -> dmRecv(context, intent) "msg_recv" -> msgRecv(context, intent) "favorite_set" -> favoriteSet( @@ -89,7 +103,11 @@ object TestHookDriver { "ptt_send" -> pttSend(context, intent) "ptt_recv" -> pttRecv(context, intent) "raw_send" -> rawSend(context, intent) + "courier_contract" -> courierContract(context) + "cache_peer_identity" -> cachePeerIdentity(context, intent.requiredString("peer")) + "sync_request" -> syncRequest(intent.requiredString("peer")) "ble" -> setBle(intent.getBooleanExtra("enabled", true)) + "wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true)) "inject_peers" -> injectPeers(intent.getStringExtra("peers")) "state" -> state(context) "clear_results" -> clearResults(context) @@ -243,6 +261,27 @@ object TestHookDriver { return ok("dm_send").put("peer", peerID).put("msg_id", id) } + /** + * Drives the same durable outbox/router path used by private-chat sends instead of the + * lower-level direct mesh API that backs `dm_send`. + */ + private fun routerPrivateSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject { + val mesh = mesh(context) + val nickname = mesh.getPeerNicknames()[peerID] ?: peerID + val id = msgID ?: "testhook-router-${System.currentTimeMillis()}" + val route = MessageRouter.getInstance(context, mesh).sendPrivate(content, peerID, nickname, id) + return ok("router_private_send") + .put("peer", peerID) + .put("msg_id", id) + .put("route", route.name) + } + + /** Recreates the durable router after a process restart without adding another message. */ + private fun routerResume(context: Context): JSONObject { + MessageRouter.getInstance(context, mesh(context)) + return ok("router_resume") + } + private suspend fun dmRecv(context: Context, intent: Intent): JSONObject { val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) val fromPeer = intent.getStringExtra("peer") @@ -276,10 +315,11 @@ object TestHookDriver { val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) val contains = intent.getStringExtra("contains") val channel = intent.getStringExtra("channel") + val includeExisting = intent.getBooleanExtra("include_existing", false) val startTime = System.currentTimeMillis() val mesh = mesh(context) val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg -> - msg.timestamp.time >= startTime && + (includeExisting || msg.timestamp.time >= startTime) && msg.senderPeerID != mesh.myPeerID && (contains == null || msg.content.contains(contains)) && (channel == null || msg.channel == channel) @@ -653,6 +693,128 @@ object TestHookDriver { .put("peer", peerID) } + /** + * Exercises the real courier wire/store implementation on a physical debug build. + * This is intentionally local: the two-phone harness has no third identity to act as + * both recipient and an independent courier, so transport scenarios cannot observe the + * spray budget without weakening the assertion. + */ + private fun courierContract(context: Context): JSONObject { + val filesDir = File(context.cacheDir, "testhook/courier-contract-files") + filesDir.deleteRecursively() + filesDir.mkdirs() + val labContext = LabStorageContext(context, filesDir) + val cipher = LabCipher(0x5a) + val now = System.currentTimeMillis() + val recipientKey = ByteArray(32) { 7 } + val depositorKey = ByteArray(32) { 8 } + val firstCourier = ByteArray(32) { 11 } + val secondCourier = ByteArray(32) { 12 } + val thirdCourier = ByteArray(32) { 13 } + val fourthCourier = ByteArray(32) { 14 } + val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now)) + val store = CourierStore(labContext, cipher) { now } + try { + val firstEnvelope = CourierEnvelope( + recipientTag = tag, + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = ByteArray(32) { (it + 1).toByte() }, + copies = 4u, + prekeyID = 0x11223344u + ) + require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED)) + val wire = requireNotNull(firstEnvelope.encode()) + val decoded = requireNotNull(CourierEnvelope.decode(wire)) + val first = store.sprayCopiesFor(firstCourier).single() + val second = store.sprayCopiesFor(secondCourier).single() + val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty() + val secondCommitted = store.commitSpray(second, secondCourier) + val firstCommitted = store.commitSpray(first, firstCourier) + val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty() + + val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() }) + require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED)) + val cancelledPreview = store.sprayCopiesFor(fourthCourier).single() + val cancelled = store.cancelSpray(cancelledPreview, fourthCourier) + val retry = store.sprayCopiesFor(fourthCourier).single() + val retryCommitted = store.commitSpray(retry, fourthCourier) + + val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now } + val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier) + .none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) } + val remainingCopies = reloaded.sprayCopiesFor(thirdCourier) + .firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) } + ?.copies + ?.toInt() + reloaded.wipe() + + require(decoded.prekeyID == firstEnvelope.prekeyID) + require(decoded.encode()?.contentEquals(wire) == true) + require(first.prekeyID == firstEnvelope.prekeyID) + require(first.copies == 2u.toUByte()) + require(second.copies == 1u.toUByte()) + require(sameCourierEmpty) + require(secondCommitted && firstCommitted && thirdCourierEmpty) + require(cancelled && retry.copies == 2u.toUByte() && retryCommitted) + require(persistedSprayHistory && remainingCopies == 1) + + return ok("courier_contract") + .put("wire_prekey_id_preserved", true) + .put("stored_prekey_id_preserved", true) + .put("first_reserved_copies", first.copies.toInt()) + .put("second_reserved_copies", second.copies.toInt()) + .put("same_courier_second_reservation_empty", sameCourierEmpty) + .put("reverse_order_commits", true) + .put("cancel_restored_eligibility", cancelled) + .put("persisted_spray_history", persistedSprayHistory) + .put("remaining_copies_after_restart", remainingCopies) + } finally { + store.wipe() + filesDir.deleteRecursively() + } + } + + /** Persist the currently authenticated peer key exactly as the normal UI session observer does. */ + private fun cachePeerIdentity(context: Context, peerID: String): JSONObject { + val info = mesh(context).getPeerInfo(peerID) + ?: return err("cache_peer_identity", "peer is not known") + val noiseKey = info.noisePublicKey + ?: return err("cache_peer_identity", "peer Noise key is unavailable") + val noiseKeyHex = noiseKey.toHex() + val identityManager = SecureIdentityStateManager(context) + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey)) + info.nickname.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname( + com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey), + nickname + ) + } + return ok("cache_peer_identity").put("peer", peerID) + } + + private fun syncRequest(peerID: String): JSONObject { + val manager = MeshServiceHolder.sharedGossipSyncManager + ?: return err("sync_request", "gossip sync manager is unavailable") + manager.scheduleInitialSyncToPeer(peerID, 0) + return ok("sync_request").put("peer", peerID) + } + + private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) { + override fun getApplicationContext(): Context = this + override fun getFilesDir(): File = labFilesDir + } + + private class LabCipher(private val mask: Int) : ConversationStorageCipher { + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray = + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray = + encrypt(envelope, associatedData) + + override fun destroyKey() = Unit + } + // MARK: - Transport / state private fun setBle(enabled: Boolean): JSONObject { @@ -661,6 +823,14 @@ object TestHookDriver { return ok("ble").put("enabled", enabled) } + private fun setWifiAware(enabled: Boolean): JSONObject { + val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled) + return ok("wifi_aware") + .put("enabled", enabled) + .put("previous_enabled", previous) + } + private fun state(context: Context): JSONObject { val mesh = mesh(context) val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f9a8c49..9599ecf8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -126,6 +126,12 @@ + + + + + + diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 7b5926ef..2493e3a3 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -73,6 +73,20 @@ class BitchatApplication : Application() { // Initialize mesh service preferences try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } + // Bridge policy is process-scoped so rendezvous and courier delivery + // continue while the activity is backgrounded. + try { + com.bitchat.android.services.bridge.MeshBridgeService.initialize(this) + com.bitchat.android.mesh.BridgeMeshPort.install( + com.bitchat.android.services.bridge.MeshBridgeService + ) + } catch (_: Exception) { } + + com.bitchat.android.services.bridge.MeshGatewayService.initialize(this) + com.bitchat.android.groups.GroupRuntime.getInstance(this) + com.bitchat.android.services.PrivateMediaOutbox.initialize(this) + com.bitchat.android.model.PeerCapabilities.setPhoneFeaturesEnabled(true) + // Proactively start the foreground service to keep mesh alive try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6f80052a..d73d9341 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -705,6 +705,7 @@ class MainActivity : OrientationAwareActivity() { // Handle any notification intent handleNotificationIntent(intent) handleVerificationIntent(intent) + handleShareIntent(intent) // Small delay to ensure mesh service is fully initialized delay(500) @@ -734,6 +735,7 @@ class MainActivity : OrientationAwareActivity() { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) handleVerificationIntent(intent) + handleShareIntent(intent) } } @@ -848,6 +850,21 @@ class MainActivity : OrientationAwareActivity() { } } + private fun handleShareIntent(intent: Intent) { + if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) { + intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotBlank() }?.let { + chatViewModel.receiveSharedText(it) + intent.removeExtra(Intent.EXTRA_TEXT) + } + } + if (intent.action == Intent.ACTION_VIEW) { + val cell = intent.data?.toString()?.let(com.bitchat.android.services.ChannelInvitation::decode) ?: return + val channel = com.bitchat.android.ui.channelForManualGeohash(cell) ?: return + LocationChannelManager.getInstance(applicationContext).selectManual(channel) + intent.data = null + } + } + private fun handleVerificationIntent(intent: Intent) { val uri = intent.data ?: return if (uri.scheme != "bitchat" || uri.host != "verify") return diff --git a/app/src/main/java/com/bitchat/android/board/BoardManager.kt b/app/src/main/java/com/bitchat/android/board/BoardManager.kt new file mode 100644 index 00000000..93288c32 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardManager.kt @@ -0,0 +1,212 @@ +package com.bitchat.android.board + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.nostr.LocationNotesManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom + +/** + * Creates and removes signed board entries while keeping UI-only state out of + * the transport layer. + */ +class BoardManager( + private val store: BoardStore, + private val scope: CoroutineScope, + private val meshProvider: () -> MeshService, + private val geoIdentityProvider: (String) -> BoardSigningIdentity? = { null }, + private val notesManager: LocationNotesManager = LocationNotesManager.getInstance(), + private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }, + private val random: SecureRandom = SecureRandom(), + private val onUrgentPosts: (geohash: String, posts: List) -> Unit = { _, _ -> } +) { + private val _unseenScopes = MutableStateFlow>(emptySet()) + private val bridgedEventIDs = mutableMapOf() + private val handledPostIDs = mutableSetOf() + private val pendingUrgent = mutableMapOf>() + private var alertFlushJob: Job? = null + + val posts: StateFlow> = store.postsSnapshot + val unseenScopes: StateFlow> = _unseenScopes.asStateFlow() + + init { + scope.launch { + store.postArrivals.collect { post -> + handleArrival(post) + } + } + } + + fun posts(geohash: String): List = store.posts(geohash.lowercase()) + + fun isOwnPost(post: BoardPostPacket): Boolean = + signingIdentityFor(post.geohash) + ?.publicKey + ?.contentEquals(post.authorSigningKey) == true + + fun createPost( + content: String, + geohash: String, + nickname: String?, + urgent: Boolean, + expiryDays: Int + ): Boolean { + val trimmed = content.trim() + val contentBytes = trimmed.toByteArray(Charsets.UTF_8) + val normalizedGeohash = geohash.lowercase() + if (contentBytes.size !in 1..BoardWireConstants.CONTENT_MAX_BYTES || + expiryDays !in 1..7 || + !isValidGeohash(normalizedGeohash) + ) { + return false + } + + val mesh = meshProvider() + val identity = signingIdentityFor(normalizedGeohash) ?: return false + val signingKey = identity.publicKey.copyOf() + val postID = ByteArray(BoardWireConstants.POST_ID_LENGTH).also(random::nextBytes) + val createdAt = nowMs() + val expiresAt = createdAt + expiryDays.toULong() * DAY_MS + val authorNickname = truncateUtf8(nickname.orEmpty(), BoardWireConstants.NICKNAME_MAX_BYTES) + val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u + val signingBytes = BoardPostPacket.signingBytes( + postID = postID, + geohash = normalizedGeohash, + content = trimmed, + authorSigningKey = signingKey, + authorNickname = authorNickname, + createdAt = createdAt, + expiresAt = expiresAt, + flags = flags + ) + val signature = identity.sign(signingBytes) + ?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } + ?: return false + val post = BoardPostPacket( + postID = postID, + geohash = normalizedGeohash, + content = trimmed, + authorSigningKey = signingKey, + authorNickname = authorNickname, + createdAt = createdAt, + expiresAt = expiresAt, + flags = flags, + signature = signature + ) + mesh.sendBoardPayload(BoardWireCodec.encode(BoardWire.Post(post))) + + if (normalizedGeohash.isNotEmpty()) { + notesManager.publishBoardBridge( + content = trimmed, + geohash = normalizedGeohash, + nickname = authorNickname, + expiresAtSeconds = (expiresAt / 1_000u).coerceAtMost(Int.MAX_VALUE.toULong()).toInt(), + urgent = urgent + ) { eventID -> + synchronized(bridgedEventIDs) { + bridgedEventIDs[post.identityKey()] = eventID + } + } + } + return true + } + + fun deletePost(post: BoardPostPacket): Boolean { + val identity = signingIdentityFor(post.geohash) + ?.takeIf { it.publicKey.contentEquals(post.authorSigningKey) } + ?: return false + val deletedAt = nowMs() + val signature = identity.sign( + BoardTombstonePacket.signingBytes(post.postID, deletedAt) + )?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false + val tombstone = BoardTombstonePacket( + postID = post.postID, + authorSigningKey = post.authorSigningKey, + deletedAt = deletedAt, + signature = signature + ) + meshProvider().sendBoardPayload(BoardWireCodec.encode(BoardWire.Tombstone(tombstone))) + + if (post.geohash.isNotEmpty()) { + val eventID = synchronized(bridgedEventIDs) { + bridgedEventIDs.remove(post.identityKey()) + } + if (eventID != null) notesManager.deleteEvent(eventID, post.geohash) + } + return true + } + + fun markSeen(scopes: Set) { + if (scopes.isEmpty()) return + _unseenScopes.value = _unseenScopes.value - scopes + } + + fun clearTransientState() { + _unseenScopes.value = emptySet() + synchronized(bridgedEventIDs) { bridgedEventIDs.clear() } + handledPostIDs.clear() + pendingUrgent.clear() + alertFlushJob?.cancel() + alertFlushJob = null + } + + private fun handleArrival(post: BoardPostPacket) { + if (!handledPostIDs.add(post.identityKey()) || isOwnPost(post)) return + _unseenScopes.value = _unseenScopes.value + post.geohash + val age = nowMs().toLong() - + post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() + if (!post.isUrgent || age > URGENT_RECENCY_MS) return + + pendingUrgent.getOrPut(post.geohash) { mutableListOf() } += post + if (alertFlushJob == null) { + alertFlushJob = scope.launch { + delay(ALERT_COLLAPSE_MS) + val pending = pendingUrgent.mapValues { it.value.toList() } + pendingUrgent.clear() + alertFlushJob = null + pending.forEach { (geohash, posts) -> onUrgentPosts(geohash, posts) } + } + } + } + + private fun isValidGeohash(value: String): Boolean = + value.isEmpty() || + (value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH && + value.all { it in BoardWireConstants.GEOHASH_ALPHABET }) + + private fun truncateUtf8(value: String, maxBytes: Int): String { + var result = value + while (result.toByteArray(Charsets.UTF_8).size > maxBytes && result.isNotEmpty()) { + result = result.dropLast(1) + } + return result + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun BoardPostPacket.identityKey(): String = + "${authorSigningKey.toHex()}:${postID.toHex()}" + + private fun signingIdentityFor(geohash: String): BoardSigningIdentity? { + if (geohash.isNotEmpty()) { + // Never fall back to the stable mesh identity for a location scope. + return runCatching { geoIdentityProvider(geohash) }.getOrNull() + } + val mesh = meshProvider() + val publicKey = mesh.getSigningPublicKey() + ?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH } + ?: return null + return BoardSigningIdentity(publicKey, mesh::signData) + } + + private companion object { + const val DAY_MS: ULong = 86_400_000uL + const val URGENT_RECENCY_MS = 30 * 60 * 1_000L + const val ALERT_COLLAPSE_MS = 4_000L + } +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardPackets.kt b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt new file mode 100644 index 00000000..62ba7464 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt @@ -0,0 +1,402 @@ +package com.bitchat.android.board + +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.CodingErrorAction +import java.security.MessageDigest + +object BoardWireConstants { + const val POST_ID_LENGTH = 16 + const val SIGNING_KEY_LENGTH = 32 + const val SIGNATURE_LENGTH = 64 + const val TRANSPORT_SENDER_ID_LENGTH = 8 + const val CONTENT_MAX_BYTES = 512 + const val NICKNAME_MAX_BYTES = 64 + const val GEOHASH_MAX_LENGTH = 12 + const val MAX_LIFETIME_MS: ULong = 604_800_000uL + const val POST_SIGNING_CONTEXT = "bitchat-board-v1" + const val TOMBSTONE_SIGNING_CONTEXT = "bitchat-board-del-v1" + const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz" +} + +class BoardPostPacket( + val postID: ByteArray, + val geohash: String, + val content: String, + val authorSigningKey: ByteArray, + val authorNickname: String, + val createdAt: ULong, + val expiresAt: ULong, + val flags: UByte, + val signature: ByteArray +) { + val isUrgent: Boolean + get() = (flags.toInt() and URGENT_FLAG.toInt()) != 0 + + val signingBytes: ByteArray + get() = signingBytes( + postID = postID, + geohash = geohash, + content = content, + authorSigningKey = authorSigningKey, + authorNickname = authorNickname, + createdAt = createdAt, + expiresAt = expiresAt, + flags = flags + ) + + fun verifySignature(): Boolean = + BoardWireCodec.verify(signature, signingBytes, authorSigningKey) + + override fun equals(other: Any?): Boolean = + other is BoardPostPacket && + postID.contentEquals(other.postID) && + geohash == other.geohash && + content == other.content && + authorSigningKey.contentEquals(other.authorSigningKey) && + authorNickname == other.authorNickname && + createdAt == other.createdAt && + expiresAt == other.expiresAt && + flags == other.flags && + signature.contentEquals(other.signature) + + override fun hashCode(): Int { + var result = postID.contentHashCode() + result = 31 * result + geohash.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + authorSigningKey.contentHashCode() + result = 31 * result + authorNickname.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + expiresAt.hashCode() + result = 31 * result + flags.hashCode() + return 31 * result + signature.contentHashCode() + } + + companion object { + const val URGENT_FLAG: UByte = 0x01u + + fun signingBytes( + postID: ByteArray, + geohash: String, + content: String, + authorSigningKey: ByteArray, + authorNickname: String, + createdAt: ULong, + expiresAt: ULong, + flags: UByte + ): ByteArray = ByteArrayOutputStream().apply { + appendContext(BoardWireConstants.POST_SIGNING_CONTEXT) + write(postID) + appendLengthPrefixed(geohash.toByteArray(Charsets.UTF_8)) + appendLengthPrefixed(content.toByteArray(Charsets.UTF_8)) + write(authorSigningKey) + appendLengthPrefixed(authorNickname.toByteArray(Charsets.UTF_8)) + appendULong(createdAt) + appendULong(expiresAt) + write(flags.toInt()) + }.toByteArray() + } +} + +class BoardTombstonePacket( + val postID: ByteArray, + val authorSigningKey: ByteArray, + val deletedAt: ULong, + val signature: ByteArray +) { + val signingBytes: ByteArray + get() = signingBytes(postID, deletedAt) + + fun verifySignature(): Boolean = + BoardWireCodec.verify(signature, signingBytes, authorSigningKey) + + override fun equals(other: Any?): Boolean = + other is BoardTombstonePacket && + postID.contentEquals(other.postID) && + authorSigningKey.contentEquals(other.authorSigningKey) && + deletedAt == other.deletedAt && + signature.contentEquals(other.signature) + + override fun hashCode(): Int { + var result = postID.contentHashCode() + result = 31 * result + authorSigningKey.contentHashCode() + result = 31 * result + deletedAt.hashCode() + return 31 * result + signature.contentHashCode() + } + + companion object { + fun signingBytes(postID: ByteArray, deletedAt: ULong): ByteArray = + ByteArrayOutputStream().apply { + appendContext(BoardWireConstants.TOMBSTONE_SIGNING_CONTEXT) + write(postID) + appendULong(deletedAt) + }.toByteArray() + } +} + +sealed interface BoardWire { + data class Post(val packet: BoardPostPacket) : BoardWire + data class Tombstone(val packet: BoardTombstonePacket) : BoardWire + + fun verifySignature(): Boolean = when (this) { + is Post -> packet.verifySignature() + is Tombstone -> packet.verifySignature() + } +} + +/** + * Board payloads authenticate their embedded author key, so their outer mesh + * sender must not expose the device's stable peer ID. The pseudonym remains + * stable only for one board signing identity. + */ +fun BoardWire.transportSenderID(): ByteArray { + val authorKey = when (this) { + is BoardWire.Post -> packet.authorSigningKey + is BoardWire.Tombstone -> packet.authorSigningKey + } + return MessageDigest.getInstance("SHA-256") + .digest(authorKey) + .copyOf(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH) +} + +object BoardWireCodec { + private const val TLV_KIND = 0x01 + private const val TLV_POST_ID = 0x02 + private const val TLV_GEOHASH = 0x03 + private const val TLV_CONTENT = 0x04 + private const val TLV_AUTHOR_SIGNING_KEY = 0x05 + private const val TLV_AUTHOR_NICKNAME = 0x06 + private const val TLV_CREATED_AT = 0x07 + private const val TLV_EXPIRES_AT = 0x08 + private const val TLV_FLAGS = 0x09 + private const val TLV_SIGNATURE = 0x0A + private const val TLV_DELETED_AT = 0x0B + private const val KIND_POST = 0x01 + private const val KIND_TOMBSTONE = 0x02 + + fun encode(wire: BoardWire): ByteArray = ByteArrayOutputStream().apply { + when (wire) { + is BoardWire.Post -> with(wire.packet) { + appendTlv(TLV_KIND, byteArrayOf(KIND_POST.toByte())) + appendTlv(TLV_POST_ID, postID) + appendTlv(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)) + appendTlv(TLV_CONTENT, content.toByteArray(Charsets.UTF_8)) + appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey) + appendTlv(TLV_AUTHOR_NICKNAME, authorNickname.toByteArray(Charsets.UTF_8)) + appendTlv(TLV_CREATED_AT, createdAt.toBigEndianBytes()) + appendTlv(TLV_EXPIRES_AT, expiresAt.toBigEndianBytes()) + appendTlv(TLV_FLAGS, byteArrayOf(flags.toByte())) + appendTlv(TLV_SIGNATURE, signature) + } + + is BoardWire.Tombstone -> with(wire.packet) { + appendTlv(TLV_KIND, byteArrayOf(KIND_TOMBSTONE.toByte())) + appendTlv(TLV_POST_ID, postID) + appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey) + appendTlv(TLV_DELETED_AT, deletedAt.toBigEndianBytes()) + appendTlv(TLV_SIGNATURE, signature) + } + } + }.toByteArray() + + fun decode(data: ByteArray): BoardWire? { + var offset = 0 + var kind: Int? = null + var postID: ByteArray? = null + var geohash: String? = null + var content: String? = null + var contentBytes = 0 + var authorSigningKey: ByteArray? = null + var authorNickname: String? = null + var nicknameBytes = 0 + var createdAt: ULong? = null + var expiresAt: ULong? = null + var flags: UByte? = null + var signature: ByteArray? = null + var deletedAt: ULong? = null + + while (offset + 3 <= data.size) { + val type = data[offset].toInt() and 0xFF + offset += 1 + val length = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + offset += 2 + if (length > data.size - offset) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + TLV_KIND -> { + if (value.size != 1) return null + kind = value[0].toInt() and 0xFF + } + TLV_POST_ID -> { + if (value.size != BoardWireConstants.POST_ID_LENGTH) return null + postID = value + } + TLV_GEOHASH -> { + if (value.size > BoardWireConstants.GEOHASH_MAX_LENGTH) return null + geohash = decodeUtf8(value) ?: return null + } + TLV_CONTENT -> { + if (value.size > BoardWireConstants.CONTENT_MAX_BYTES) return null + contentBytes = value.size + content = decodeUtf8(value) ?: return null + } + TLV_AUTHOR_SIGNING_KEY -> { + if (value.size != BoardWireConstants.SIGNING_KEY_LENGTH) return null + authorSigningKey = value + } + TLV_AUTHOR_NICKNAME -> { + if (value.size > BoardWireConstants.NICKNAME_MAX_BYTES) return null + nicknameBytes = value.size + authorNickname = decodeUtf8(value) ?: return null + } + TLV_CREATED_AT -> createdAt = value.toULongBigEndian() ?: return null + TLV_EXPIRES_AT -> expiresAt = value.toULongBigEndian() ?: return null + TLV_FLAGS -> { + if (value.size != 1) return null + flags = value[0].toUByte() + } + TLV_SIGNATURE -> { + if (value.size != BoardWireConstants.SIGNATURE_LENGTH) return null + signature = value + } + TLV_DELETED_AT -> deletedAt = value.toULongBigEndian() ?: return null + } + } + + val requiredPostID = postID ?: return null + val requiredKey = authorSigningKey ?: return null + val requiredSignature = signature ?: return null + return when (kind) { + KIND_POST -> { + val requiredGeohash = geohash ?: return null + val requiredContent = content ?: return null + val requiredNickname = authorNickname ?: return null + val requiredCreatedAt = createdAt ?: return null + val requiredExpiresAt = expiresAt ?: return null + val requiredFlags = flags ?: return null + if (contentBytes !in 1..BoardWireConstants.CONTENT_MAX_BYTES) return null + if (nicknameBytes > BoardWireConstants.NICKNAME_MAX_BYTES) return null + if (!isValidGeohash(requiredGeohash)) return null + if (requiredExpiresAt <= requiredCreatedAt) return null + if (requiredExpiresAt - requiredCreatedAt > BoardWireConstants.MAX_LIFETIME_MS) return null + BoardWire.Post( + BoardPostPacket( + postID = requiredPostID, + geohash = requiredGeohash, + content = requiredContent, + authorSigningKey = requiredKey, + authorNickname = requiredNickname, + createdAt = requiredCreatedAt, + expiresAt = requiredExpiresAt, + flags = requiredFlags, + signature = requiredSignature + ) + ) + } + + KIND_TOMBSTONE -> BoardWire.Tombstone( + BoardTombstonePacket( + postID = requiredPostID, + authorSigningKey = requiredKey, + deletedAt = deletedAt ?: return null, + signature = requiredSignature + ) + ) + + else -> null + } + } + + fun urgentFlag(data: ByteArray): Boolean { + var offset = 0 + while (offset + 3 <= data.size) { + val type = data[offset].toInt() and 0xFF + offset += 1 + val length = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + offset += 2 + if (length > data.size - offset) return false + if (type == TLV_FLAGS && length == 1) { + return (data[offset].toInt() and BoardPostPacket.URGENT_FLAG.toInt()) != 0 + } + offset += length + } + return false + } + + internal fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean = + try { + if (signature.size != BoardWireConstants.SIGNATURE_LENGTH || + publicKey.size != BoardWireConstants.SIGNING_KEY_LENGTH + ) { + false + } else { + Ed25519Signer().run { + init(false, Ed25519PublicKeyParameters(publicKey, 0)) + update(message, 0, message.size) + verifySignature(signature) + } + } + } catch (_: Exception) { + false + } + + private fun isValidGeohash(value: String): Boolean = + value.isEmpty() || + (value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH && + value.all { it in BoardWireConstants.GEOHASH_ALPHABET }) + + private fun decodeUtf8(value: ByteArray): String? = + try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(value)) + .toString() + } catch (_: Exception) { + null + } +} + +private fun ByteArrayOutputStream.appendTlv(type: Int, value: ByteArray) { + require(value.size <= 0xFFFF) + write(type) + write((value.size ushr 8) and 0xFF) + write(value.size and 0xFF) + write(value) +} + +private fun ByteArrayOutputStream.appendContext(context: String) { + val value = context.toByteArray(Charsets.UTF_8).take(255).toByteArray() + write(value.size) + write(value) +} + +private fun ByteArrayOutputStream.appendLengthPrefixed(value: ByteArray) { + val limited = value.take(0xFFFF).toByteArray() + write((limited.size ushr 8) and 0xFF) + write(limited.size and 0xFF) + write(limited) +} + +private fun ByteArrayOutputStream.appendULong(value: ULong) { + write(value.toBigEndianBytes()) +} + +private fun ULong.toBigEndianBytes(): ByteArray = + ByteArray(8) { index -> (this shr ((7 - index) * 8)).toByte() } + +private fun ByteArray.toULongBigEndian(): ULong? { + if (size != 8) return null + var value = 0uL + for (byte in this) { + value = (value shl 8) or (byte.toULong() and 0xFFuL) + } + return value +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt new file mode 100644 index 00000000..a9ab35d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.board + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.MessageDigest + +/** + * Signing identity used by board payloads. + * + * Location boards use a key derived from the already-unlinkable per-geohash + * Nostr secret. Domain separation keeps the board Ed25519 identity distinct + * from the secp256k1 identity used on relays. + */ +class BoardSigningIdentity( + publicKey: ByteArray, + private val signer: (ByteArray) -> ByteArray? +) { + val publicKey: ByteArray = publicKey.copyOf() + + init { + require(publicKey.size == BoardWireConstants.SIGNING_KEY_LENGTH) + } + + fun sign(message: ByteArray): ByteArray? = signer(message)?.copyOf() + + companion object { + private const val GEO_IDENTITY_CONTEXT = "bitchat-board-geo-identity-v1" + + fun fromNostrPrivateKeyHex(privateKeyHex: String): BoardSigningIdentity? = + runCatching { + val nostrSecret = privateKeyHex.hexToByteArray() + .takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH } + ?: return@runCatching null + val digest = MessageDigest.getInstance("SHA-256") + digest.update(GEO_IDENTITY_CONTEXT.toByteArray(Charsets.UTF_8)) + digest.update(nostrSecret) + fromEd25519Seed(digest.digest()) + }.getOrNull() + + fun fromEd25519Seed(seed: ByteArray): BoardSigningIdentity { + require(seed.size == BoardWireConstants.SIGNING_KEY_LENGTH) + val privateKey = Ed25519PrivateKeyParameters(seed.copyOf(), 0) + return BoardSigningIdentity(privateKey.generatePublicKey().encoded) { message -> + Ed25519Signer().run { + init(true, privateKey) + update(message, 0, message.size) + generateSignature() + } + } + } + + private fun String.hexToByteArray(): ByteArray { + require(length % 2 == 0) + return ByteArray(length / 2) { index -> + substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardStore.kt b/app/src/main/java/com/bitchat/android/board/BoardStore.kt new file mode 100644 index 00000000..19bfc1a5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardStore.kt @@ -0,0 +1,382 @@ +package com.bitchat.android.board + +import android.content.Context +import android.util.Log +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.util.Base64 + +enum class BoardIngestResult { + ACCEPTED, + DUPLICATE, + REJECTED +} + +enum class BoardIngestSource { + REMOTE, + LOCAL, + RESTORE +} + +class BoardStore( + private val file: File? = null, + private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() } +) { + object Limits { + const val MAX_POSTS = 200 + const val MAX_POSTS_PER_AUTHOR = 5 + const val MAX_ORPHAN_TOMBSTONES = 100 + const val MAX_ORPHAN_TOMBSTONES_PER_AUTHOR = 5 + const val CLOCK_SKEW_MS: ULong = 3_600_000uL + const val ORPHAN_TOMBSTONE_LIFETIME_MS: ULong = BoardWireConstants.MAX_LIFETIME_MS + } + + private data class StoredPost( + val post: BoardPostPacket, + val packet: BitchatPacket, + val rawPacket: ByteArray + ) + + private data class StoredTombstone( + val tombstone: BoardTombstonePacket, + val packet: BitchatPacket, + val rawPacket: ByteArray, + val retainUntil: ULong, + val isOrphan: Boolean + ) + + private data class PersistedEntry( + val packet: String, + val retainUntil: String? + ) + + private val lock = Any() + private val posts = mutableListOf() + private val tombstones = mutableListOf() + private val _postsSnapshot = MutableStateFlow>(emptyList()) + private val _postArrivals = MutableSharedFlow(extraBufferCapacity = 64) + + val postsSnapshot: StateFlow> = _postsSnapshot.asStateFlow() + val postArrivals: SharedFlow = _postArrivals.asSharedFlow() + + init { + loadFromDisk() + } + + fun ingest( + wire: BoardWire, + packet: BitchatPacket, + source: BoardIngestSource = BoardIngestSource.REMOTE + ): BoardIngestResult { + if (packet.type != MessageType.BOARD_POST.value || !wire.verifySignature()) { + return BoardIngestResult.REJECTED + } + val rawPacket = packet.toBinaryData(padding = false) ?: return BoardIngestResult.REJECTED + val now = nowMs() + var arrival: BoardPostPacket? = null + val result = synchronized(lock) { + val outcome = ingestLocked( + wire = wire, + packet = packet, + rawPacket = rawPacket, + now = now, + retainUntilOverride = null + ) + if (outcome == BoardIngestResult.ACCEPTED && source != BoardIngestSource.RESTORE) { + persistLocked() + } + if (outcome == BoardIngestResult.ACCEPTED && + source == BoardIngestSource.REMOTE && + wire is BoardWire.Post + ) { + arrival = wire.packet + } + outcome + } + arrival?.let(_postArrivals::tryEmit) + return result + } + + /** + * Ingests a packet received from the mesh and reports whether this exact + * arrival may continue through the live relay path. + * + * A duplicate board payload must not relay again: the payload signature + * does not authenticate mutable outer packet fields such as timestamp, so + * accepting duplicates for relay would let one captured notice be wrapped + * in infinitely many distinct outer packets. + */ + fun ingestRemoteForRelay(wire: BoardWire, packet: BitchatPacket): Boolean = + ingest(wire, packet, BoardIngestSource.REMOTE) == BoardIngestResult.ACCEPTED + + fun posts(forGeohash: String): List = synchronized(lock) { + pruneExpiredLocked(nowMs()) + posts.asSequence() + .map { it.post } + .filter { it.geohash == forGeohash } + .sortedWith( + compareByDescending { it.isUrgent } + .thenByDescending { it.createdAt } + ) + .toList() + } + + fun syncCandidates(): List = synchronized(lock) { + pruneExpiredLocked(nowMs()) + posts.map { it.packet } + tombstones.map { it.packet } + } + + fun pruneExpired() = synchronized(lock) { + val changed = pruneExpiredLocked(nowMs()) + if (changed) persistLocked() + } + + fun wipe() = synchronized(lock) { + posts.clear() + tombstones.clear() + file?.let { check(!it.exists() || it.delete()) } + publishSnapshotLocked() + } + + private fun ingestLocked( + wire: BoardWire, + packet: BitchatPacket, + rawPacket: ByteArray, + now: ULong, + retainUntilOverride: ULong? + ): BoardIngestResult { + pruneExpiredLocked(now) + return when (wire) { + is BoardWire.Post -> ingestPostLocked(wire.packet, packet, rawPacket, now) + is BoardWire.Tombstone -> ingestTombstoneLocked( + wire.packet, + packet, + rawPacket, + now, + retainUntilOverride + ) + } + } + + private fun ingestPostLocked( + post: BoardPostPacket, + packet: BitchatPacket, + rawPacket: ByteArray, + now: ULong + ): BoardIngestResult { + if (post.expiresAt <= now) return BoardIngestResult.REJECTED + if (post.createdAt > now.saturatedAdd(Limits.CLOCK_SKEW_MS)) { + return BoardIngestResult.REJECTED + } + if (post.expiresAt > now.saturatedAdd(BoardWireConstants.MAX_LIFETIME_MS) + .saturatedAdd(Limits.CLOCK_SKEW_MS) + ) { + return BoardIngestResult.REJECTED + } + if (tombstones.any { + it.tombstone.postID.contentEquals(post.postID) && + it.tombstone.authorSigningKey.contentEquals(post.authorSigningKey) + } + ) { + return BoardIngestResult.REJECTED + } + if (posts.any { + it.post.postID.contentEquals(post.postID) && + it.post.authorSigningKey.contentEquals(post.authorSigningKey) + } + ) { + return BoardIngestResult.DUPLICATE + } + + posts += StoredPost(post, packet, rawPacket) + enforcePostCapsLocked(post.authorSigningKey) + publishSnapshotLocked() + return BoardIngestResult.ACCEPTED + } + + private fun ingestTombstoneLocked( + tombstone: BoardTombstonePacket, + packet: BitchatPacket, + rawPacket: ByteArray, + now: ULong, + retainUntilOverride: ULong? + ): BoardIngestResult { + if (tombstones.any { + it.tombstone.postID.contentEquals(tombstone.postID) && + it.tombstone.authorSigningKey.contentEquals(tombstone.authorSigningKey) + } + ) { + return BoardIngestResult.DUPLICATE + } + + val maxRetain = minOf( + tombstone.deletedAt.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS), + now.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS) + .saturatedAdd(Limits.CLOCK_SKEW_MS) + ) + val matchingPostIndex = posts.indexOfFirst { + it.post.postID.contentEquals(tombstone.postID) && + it.post.authorSigningKey.contentEquals(tombstone.authorSigningKey) + } + val retainUntil: ULong + val isOrphan: Boolean + if (matchingPostIndex >= 0) { + val target = posts[matchingPostIndex].post + retainUntil = target.expiresAt + isOrphan = false + posts.removeAt(matchingPostIndex) + publishSnapshotLocked() + } else if (retainUntilOverride != null) { + retainUntil = minOf(retainUntilOverride, maxRetain) + isOrphan = false + } else { + retainUntil = maxRetain + isOrphan = true + } + if (retainUntil <= now) return BoardIngestResult.REJECTED + + tombstones += StoredTombstone( + tombstone = tombstone, + packet = packet, + rawPacket = rawPacket, + retainUntil = retainUntil, + isOrphan = isOrphan + ) + if (isOrphan) enforceOrphanTombstoneCapsLocked(tombstone.authorSigningKey) + return BoardIngestResult.ACCEPTED + } + + private fun enforcePostCapsLocked(author: ByteArray) { + val authorPosts = posts.filter { it.post.authorSigningKey.contentEquals(author) } + evictOldestPostsLocked(authorPosts, Limits.MAX_POSTS_PER_AUTHOR) + evictOldestPostsLocked(posts.toList(), Limits.MAX_POSTS) + } + + private fun evictOldestPostsLocked(candidates: List, keep: Int) { + val victims = candidates.sortedBy { it.post.createdAt } + .take((candidates.size - keep).coerceAtLeast(0)) + if (victims.isNotEmpty()) { + posts.removeAll { stored -> victims.any { it === stored } } + } + } + + private fun enforceOrphanTombstoneCapsLocked(author: ByteArray) { + val authorOrphans = tombstones.filter { + it.isOrphan && it.tombstone.authorSigningKey.contentEquals(author) + } + removeOldestTombstonesLocked( + authorOrphans, + authorOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES_PER_AUTHOR + ) + val allOrphans = tombstones.filter { it.isOrphan } + removeOldestTombstonesLocked( + allOrphans, + allOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES + ) + } + + private fun removeOldestTombstonesLocked( + candidates: List, + count: Int + ) { + if (count <= 0) return + val victims = candidates.take(count) + tombstones.removeAll { stored -> victims.any { it === stored } } + } + + private fun pruneExpiredLocked(now: ULong): Boolean { + val postsBefore = posts.size + val tombstonesBefore = tombstones.size + posts.removeAll { it.post.expiresAt <= now } + tombstones.removeAll { it.retainUntil <= now } + if (posts.size != postsBefore) publishSnapshotLocked() + return posts.size != postsBefore || tombstones.size != tombstonesBefore + } + + private fun publishSnapshotLocked() { + _postsSnapshot.value = posts.map { it.post } + } + + private fun persistLocked() { + val target = file ?: return + val entries = posts.map { + PersistedEntry( + packet = Base64.getEncoder().encodeToString(it.rawPacket), + retainUntil = null + ) + } + tombstones.map { + PersistedEntry( + packet = Base64.getEncoder().encodeToString(it.rawPacket), + retainUntil = it.retainUntil.toString() + ) + } + runCatching { + if (entries.isEmpty()) { + if (target.exists()) target.delete() + return + } + target.parentFile?.mkdirs() + val temporary = File(target.parentFile, "${target.name}.tmp") + temporary.writeText(Gson().toJson(entries)) + if (!temporary.renameTo(target)) { + temporary.copyTo(target, overwrite = true) + temporary.delete() + } + }.onFailure { + Log.e(TAG, "Failed to persist board store: ${it.message}") + } + } + + private fun loadFromDisk() { + val target = file ?: return + if (!target.isFile) return + val entries = runCatching { + val type = object : TypeToken>() {}.type + Gson().fromJson>(target.readText(), type) + }.getOrNull() ?: return + val now = nowMs() + synchronized(lock) { + for (entry in entries) { + val raw = runCatching { Base64.getDecoder().decode(entry.packet) }.getOrNull() ?: continue + val packet = BitchatPacket.fromBinaryData(raw) ?: continue + if (packet.type != MessageType.BOARD_POST.value) continue + val wire = BoardWireCodec.decode(packet.payload) ?: continue + if (!wire.verifySignature()) continue + ingestLocked( + wire = wire, + packet = packet, + rawPacket = raw, + now = now, + retainUntilOverride = entry.retainUntil?.toULongOrNull() + ) + } + publishSnapshotLocked() + } + } + + companion object { + private const val TAG = "BoardStore" + + @Volatile + private var instance: BoardStore? = null + + fun getInstance(context: Context): BoardStore = + instance ?: synchronized(this) { + instance ?: BoardStore( + File(context.applicationContext.filesDir, "board/posts.json") + ).also { instance = it } + } + } +} + +private fun ULong.saturatedAdd(other: ULong): ULong = + if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other diff --git a/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt new file mode 100644 index 00000000..c91b127b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt @@ -0,0 +1,87 @@ +package com.bitchat.android.board + +import com.bitchat.android.nostr.LocationNotesManager +import kotlin.math.abs + +enum class NoticeSource { + MESH, + NOSTR +} + +data class UnifiedNotice( + val id: String, + val content: String, + val nickname: String, + val createdAtMs: Long, + val geohash: String, + val urgent: Boolean, + val expiresAtMs: Long?, + val source: NoticeSource, + val boardPost: BoardPostPacket? = null, + val nostrNote: LocationNotesManager.Note? = null +) + +object UnifiedNotices { + private const val DEDUPLICATION_WINDOW_MS = 15 * 60 * 1_000L + + /** + * Combines exact-scope mesh board posts with relay notes. When a relay + * bridge copy matches a board post, the signed board copy is authoritative. + */ + fun merge( + geohash: String, + boardPosts: List, + relayNotes: List + ): List { + val normalized = geohash.lowercase() + val scopedPosts = boardPosts.filter { it.geohash == normalized } + val boardNotices = scopedPosts.map { post -> + UnifiedNotice( + id = "mesh:${post.authorSigningKey.toHex()}:${post.postID.toHex()}", + content = post.content, + nickname = post.authorNickname, + createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + geohash = post.geohash, + urgent = post.isUrgent, + expiresAtMs = post.expiresAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + source = NoticeSource.MESH, + boardPost = post + ) + } + val relayNotices = relayNotes.asSequence() + .filterNot { note -> + scopedPosts.any { post -> + post.geohash == note.geohash.lowercase() && + post.content == note.content && + post.authorNickname.ifBlank { "anon" } == + note.nickname?.trim()?.takeIf { it.isNotEmpty() }.orEmpty() + .ifEmpty { "anon" } && + abs( + post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() - + note.createdAt.toLong() * 1_000L + ) <= DEDUPLICATION_WINDOW_MS + } + } + .map { note -> + UnifiedNotice( + id = "nostr:${note.id}", + content = note.content, + nickname = note.nickname.orEmpty(), + createdAtMs = note.createdAt.toLong() * 1_000L, + geohash = note.geohash.lowercase(), + urgent = note.isUrgent, + expiresAtMs = note.expiresAt?.toLong()?.times(1_000L), + source = NoticeSource.NOSTR, + nostrNote = note + ) + } + .toList() + + return (boardNotices + relayNotices).sortedWith( + compareByDescending { it.urgent } + .thenByDescending { it.createdAtMs } + ) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index b15a6254..8f078a3d 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -112,6 +112,12 @@ open class EncryptionService(private val context: Context) { fun getStaticPublicKey(): ByteArray? { return noiseService.getStaticPublicKeyData() } + + fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray = + noiseService.sealCourierPayload(payload, recipientStaticKey) + + fun openCourierPayload(ciphertext: ByteArray): Pair = + noiseService.openCourierPayload(ciphertext) /** * Get our signing public key for Ed25519 signatures (for identity announcements) diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt index da6898e8..2d8c04ea 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -280,7 +280,7 @@ object FileUtils { * Recursively delete all media files (incoming and outgoing) * Used for Panic Mode cleanup */ - fun clearAllMedia(context: Context) { + fun clearAllMedia(context: Context): Boolean { try { // Clear files dir subdirectories (legacy storage and outgoing) val filesDir = context.filesDir @@ -295,7 +295,7 @@ object FileUtils { dirsToClear.forEach { subDir -> val dir = File(filesDir, subDir) if (dir.exists()) { - dir.deleteRecursively() + check(dir.deleteRecursively()) Log.d(TAG, "Deleted media directory from filesDir: $subDir") } } @@ -312,17 +312,19 @@ object FileUtils { cacheDirsToClear.forEach { subDir -> val dir = File(cacheDir, subDir) if (dir.exists()) { - dir.deleteRecursively() + check(dir.deleteRecursively()) Log.d(TAG, "Deleted media directory from cacheDir: $subDir") } } // Also clear entire cache dir as a catch-all - context.cacheDir.deleteRecursively() + check(!context.cacheDir.exists() || context.cacheDir.deleteRecursively()) Log.d(TAG, "Cleared entire cache directory") + return true } catch (e: Exception) { Log.e(TAG, "Failed to clear media files", e) + return false } } diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt index 05223b48..ce6bc273 100644 --- a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt @@ -252,14 +252,22 @@ class LiveVoiceManager private constructor(private val context: Context) { } ?: return false val finished = entry.value val replacement = message.copy( - id = finished.messageID, + id = if (message.isPrivate && com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(message.id)) message.id else finished.messageID, timestamp = finished.timestamp, sender = finished.nickname, senderPeerID = peerID, isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE ) if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) { - AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID)) + if (replacement.id != finished.messageID) { + val saved = kotlinx.coroutines.runBlocking { + AppStateStore.addPrivateMessageDurably(peerID, replacement, isVisible(messageScope, peerID)) + } + if (!saved) return false + AppStateStore.removePrivateMessage(finished.messageID) + } else { + AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID)) + } } else { AppStateStore.upsertPublicMessage(replacement) } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 46f05950..c793c163 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) { /** * Refresh available channels from current location */ - fun refreshChannels() { + fun refreshChannels( + forceFresh: Boolean = false, + updatePlaceNames: Boolean = true + ) { if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) { - requestOneShotLocation() + requestOneShotLocation(forceFresh, updatePlaceNames) } } @@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) { // MARK: - Location Operations - private fun requestOneShotLocation() { + private fun requestOneShotLocation( + forceFresh: Boolean = false, + updatePlaceNames: Boolean = true + ) { if (!isLocationServicesEnabled() || syncPermissionState() != PermissionState.AUTHORIZED ) { @@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) { val token = LiveLocationPrivacyGate.captureToken() ?: return _isLoadingLocation.value = true + if (forceFresh) { + requestFreshLocation(token, updatePlaceNames) + return + } + val started = LiveLocationPrivacyGate.runIfAllowed(token) { locationProvider.getLastKnownLocation { cached -> if (!canUseLiveLocation(token)) return@getLastKnownLocation if (cached != null) { - onLocationUpdated(cached, token) + onLocationUpdated(cached, token, updatePlaceNames) } else { - LiveLocationPrivacyGate.runIfAllowed(token) { - locationProvider.requestFreshLocation { fresh -> - if (!canUseLiveLocation(token)) return@requestFreshLocation - - if (fresh != null) { - onLocationUpdated(fresh, token) - } else { - Log.w(TAG, "Failed to get fresh location") - _isLoadingLocation.value = false - } - } - } + requestFreshLocation(token, updatePlaceNames) } } } if (!started) _isLoadingLocation.value = false } - private fun onLocationUpdated(location: Location, token: Long) { + private fun requestFreshLocation( + token: Long, + updatePlaceNames: Boolean + ) { + val started = LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.requestFreshLocation { fresh -> + if (!canUseLiveLocation(token)) return@requestFreshLocation + if (fresh != null) { + onLocationUpdated(fresh, token, updatePlaceNames) + } else { + Log.w(TAG, "Failed to get fresh location") + _isLoadingLocation.value = false + } + } + } + if (!started) _isLoadingLocation.value = false + } + + private fun onLocationUpdated( + location: Location, + token: Long, + updatePlaceNames: Boolean = true + ) { LiveLocationPrivacyGate.runIfAllowed(token) { if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed _isLoadingLocation.value = false computeChannels(location, token) - reverseGeocodeIfNeeded(location, token) + if (updatePlaceNames) reverseGeocodeIfNeeded(location, token) } } @@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) { _teleported.value = false } + /** Remove exact/transient location state without destroying the singleton. */ + fun panicReset() { + clearLiveLocationState() + clearPersistedChannel() + } + // MARK: - Location Services State Persistence /** diff --git a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt new file mode 100644 index 00000000..c914cf82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt @@ -0,0 +1,715 @@ +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 + 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() + private val futureMessages = ArrayDeque() + @Volatile + private var acceptsInboundEvents = true + @Volatile + private var inboundGeneration = 0L + + @Synchronized + fun createGroup(rawName: String): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") + if (!context.groupStore.isReady) return loadingError() + val name = rawName.trim() + if (name.isEmpty()) return error("usage: /group create ") + 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}") + } + + @Synchronized + fun inviteMember(rawNickname: String): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") + if (!context.groupStore.isReady) return loadingError() + val selector = parseMemberSelector(rawNickname) + ?: return error("usage: /group invite [#identity-suffix]") + val nickname = selector.nickname + val group = selectedGroup() ?: return error("open a private group first") + if (!isCreator(group)) return error("only the group creator can change members") + val peerID = 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}") + } + + @Synchronized + fun removeMember(rawNickname: String): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") + if (!context.groupStore.isReady) return loadingError() + val selector = parseMemberSelector(rawNickname) + ?: return error("usage: /group remove [#identity-suffix]") + val nickname = selector.nickname + val group = selectedGroup() ?: return error("open a private group first") + if (!isCreator(group)) return error("only the group creator can change members") + val 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") + } + + @Synchronized + fun leaveGroup(): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") + 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}") + } + + @Synchronized + 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") + } + + @Synchronized + fun sendMessage(content: String, groupPeerID: String): Boolean { + if (!acceptsInboundEvents) return false + if (!context.groupStore.isReady) { + context.addGroupSystemMessage(groupPeerID, "private groups are still loading") + return false + } + if (content.isEmpty() || + content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH + ) { + return false + } + 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 false + } + val signingKey = context.mySigningPublicKey() + if (signingKey?.size != 32) { + context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable") + return false + } + + 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 false + } + + val stored = 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 + ) + ) + if (!stored) return false + context.broadcastGroupMessage(payload) + return true + } + + @Synchronized + 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) + } + } + } + + @Synchronized + 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) + } + } + + @Synchronized + 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. + */ + @Synchronized + 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. + */ + @Synchronized + 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) + } + } + } + } + + @Synchronized + fun suspendForPanic() { + synchronized(lifecycleLock) { + acceptsInboundEvents = false + inboundGeneration += 1 + synchronized(pendingLock) { + pendingEvents.clear() + } + futureMessages.clear() + } + } + + @Synchronized + 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 + ) { + 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): String = + members.joinToString(" or ") { "@${it.nickname}#${it.fingerprint.takeLast(8)}" } + + private fun queueFutureMessage(envelope: GroupMessageEnvelope, payload: ByteArray) { + val now = System.currentTimeMillis() + pruneExpiredFutureMessages(now) + if (futureMessages.any { + it.epoch == envelope.epoch && + it.groupID.contentEquals(envelope.groupID) && + it.payload.contentEquals(payload) + } + ) { + return + } + if (futureMessages.size >= MAX_FUTURE_MESSAGES) futureMessages.pollFirst() + futureMessages.addLast( + FutureMessage( + envelope.groupID.copyOf(), + envelope.epoch, + payload.copyOf(), + now + ) + ) + } + + private fun retryFutureMessages(groupID: ByteArray) { + val current = context.groupStore.group(groupID) ?: return + val now = System.currentTimeMillis() + val ready = mutableListOf() + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + val message = iterator.next() + if (now - message.queuedAtMs > FUTURE_MESSAGE_TTL_MS) { + iterator.remove() + } else if (message.groupID.contentEquals(groupID) && + message.epoch <= current.epoch + ) { + iterator.remove() + if (message.epoch == current.epoch) ready += message.payload + } + } + ready.forEach { processMessage(it, queueFutureEpoch = false) } + } + + private fun dropFutureMessages(groupID: ByteArray) { + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + if (iterator.next().groupID.contentEquals(groupID)) iterator.remove() + } + } + + private fun pruneExpiredFutureMessages(now: Long) { + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + if (now - iterator.next().queuedAtMs > FUTURE_MESSAGE_TTL_MS) iterator.remove() + } + } + + private fun deferIfLoading(event: PendingEvent): Boolean = + synchronized(pendingLock) { + 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}$") + } +} diff --git a/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt new file mode 100644 index 00000000..2f8a25de --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt @@ -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, + 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): ByteArray { + val output = ByteArrayOutputStream() + fields.forEach { (type, value) -> put(type, value, output) } + return output.toByteArray() + } + + fun parse(data: ByteArray): List? { + val fields = mutableListOf() + 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): 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? { + if (data.isEmpty()) return null + val count = data[0].toInt() and 0xff + if (count > BitchatGroup.MAX_MEMBERS) return null + val members = mutableListOf() + 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, + 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? = 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() +} diff --git a/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt new file mode 100644 index 00000000..d0622531 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.groups + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.mesh.GroupMessagePort +import com.bitchat.android.mesh.GroupMessageReceiver +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.service.MeshServiceHolder +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.ui.DataManager +import java.lang.ref.WeakReference +import java.util.Date +import kotlinx.coroutines.* + +interface GroupUiDelegate { + val nickname: String + fun markGroupUnread(groupPeerID: String) + fun openGroupConversation(groupPeerID: String) + fun closeGroupConversation() +} + +/** Keeps group membership, decryption, and durable delivery alive when the Activity closes. */ +class GroupRuntime private constructor(private val application: Context) : GroupMessageReceiver { + val store = GroupStore(application) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile private var ui = WeakReference(null) + private val mesh get() = MeshServiceHolder.unifiedMeshService + private val notifications = com.bitchat.android.ui.NotificationManager(application, NotificationManagerCompat.from(application)) + + val coordinator = GroupCoordinator(object : GroupCoordinatorContext { + override val groupStore get() = store + override val nickname get() = ui.get()?.nickname ?: AppStateStore.nickname.value + override val myPeerID get() = mesh?.myPeerID.orEmpty() + override val selectedConversationID get() = AppStateStore.selectedPrivateChatPeer.value + override fun myNoiseFingerprint() = mesh?.getIdentityFingerprint().orEmpty() + override fun mySigningPublicKey() = mesh?.getSigningPublicKey() + override fun sign(data: ByteArray) = mesh?.signData(data) + override fun peerIDsForNickname(nickname: String) = mesh?.getPeerNicknames().orEmpty() + .filterValues { it.equals(nickname, ignoreCase = true) }.keys.toList() + override fun isPeerConnected(peerID: String) = mesh?.getPeerInfo(peerID)?.isConnected == true && mesh?.hasEstablishedSession(peerID) == true + override fun peerGroupCapability(peerID: String): PeerGroupCapability { + val peer = mesh?.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN + return PeerGroupCapability.fromPeerState(peer.capabilities, peer.hasVerifiedAnnouncement) + } + override fun peerNickname(peerID: String) = mesh?.getPeerNicknames()?.get(peerID) + override fun peerIdentity(peerID: String): GroupPeerIdentity? { + val info = mesh?.getPeerInfo(peerID) ?: return null + val signing = info.signingPublicKey?.takeIf { it.size == 32 } ?: return null + val key = info.noisePublicKey ?: return null + val fingerprint = ContactIdentityResolver.fingerprintHex(key) + if (!fingerprint.equals(mesh?.getPeerFingerprint(peerID), ignoreCase = true)) return null + return GroupPeerIdentity(fingerprint, signing.copyOf()) + } + override fun connectedPeerID(fingerprint: String) = mesh?.getPeerNicknames()?.keys?.firstOrNull { + isPeerConnected(it) && fingerprint.equals(mesh?.getPeerFingerprint(it), ignoreCase = true) + } + override fun isFingerprintBlocked(fingerprint: String) = DataManager.isFingerprintBlocked(application, 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 = runBlocking { + AppStateStore.addPrivateMessageDurably(groupPeerID, message, selectedConversationID == groupPeerID || message.senderPeerID == myPeerID) + } + override fun markGroupUnread(groupPeerID: String) { ui.get()?.markGroupUnread(groupPeerID) } + override fun removeGroupConversation(groupPeerID: String) { AppStateStore.deletePrivateConversation(groupPeerID) } + override fun openGroupConversation(groupPeerID: String) { scope.launch(Dispatchers.Main) { ui.get()?.openGroupConversation(groupPeerID) } } + override fun closeGroupConversation() { scope.launch(Dispatchers.Main) { ui.get()?.closeGroupConversation() } } + override fun addSystemMessage(message: String) { AppStateStore.addPublicMessage(BitchatMessage(sender = "system", content = message, timestamp = Date())) } + 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.showPrivateMessageNotification(groupPeerID, sender, message) + } + }) + + init { + GroupMessagePort.receiver = this + scope.launch { if (store.initialize()) coordinator.onStoreReady() } + } + + fun attach(context: GroupUiDelegate) { ui = WeakReference(context) } + fun detach(context: GroupUiDelegate) { if (ui.get() === context) ui.clear() } + override fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleInvite(peerID, authenticatedKey, payload) + override fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleKeyUpdate(peerID, authenticatedKey, payload) + override fun message(payload: ByteArray, timestampMs: Long) = coordinator.handleMessage(payload, timestampMs) + override fun peerAuthenticated(peerID: String) { scope.launch { coordinator.handlePeerAuthenticated(peerID) } } + + companion object { + @Volatile private var instance: GroupRuntime? = null + fun getInstance(context: Context): GroupRuntime = instance ?: synchronized(this) { + instance ?: GroupRuntime(context.applicationContext).also { instance = it } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/groups/GroupStore.kt b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt new file mode 100644 index 00000000..0f63ee9a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt @@ -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, + val creatorFingerprint: String + ) + + private data class StoredState( + val version: Int = 1, + val groups: List, + val departures: Map + ) + + private val lock = Any() + private val gson = Gson() + private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() } + private val _groups = MutableStateFlow>(emptyList()) + private val departures = mutableMapOf() + private var keyStorage: GroupKeyStorage? = null + private var metadataStorage: GroupMetadataStorage? = null + + @Volatile + private var initialized = false + + val groups: StateFlow> = _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 + ): Pair? = 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, + departures: Map + ): 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>() {}.type + StoredState(groups = gson.fromJson(json, type), departures = emptyMap()) + } else { + val objectValue = json.asJsonObject + val groupType = object : TypeToken>() {}.type + val departureType = object : TypeToken>() {}.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}$") + } +} diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 1efa87a2..574fea68 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -10,8 +10,11 @@ import android.util.Base64 import android.util.Log import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation import com.bitchat.android.util.hexEncodedString import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow /** * Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation @@ -37,22 +40,38 @@ class SecureIdentityStateManager { private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1" private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1" + private const val KEY_VOUCH_RECORDS = "vouch_records_v1" + private const val KEY_VOUCH_BATCH_SENT_AT = "vouch_batch_sent_at_v1" + private const val KEY_VERIFIED_AT = "verified_at_v1" + const val MAX_VOUCHERS_PER_VOUCHEE = 8 + private const val VOUCH_RECORD_FIELD_COUNT = 4 + private const val RECORD_SEPARATOR = ':' + private const val RECORD_SEPARATOR_LENGTH = 1 + private const val VOUCHEE_FIELD_INDEX = 0 + private const val VOUCHER_FIELD_INDEX = 1 + private const val VOUCHEE_SIGNING_KEY_FIELD_INDEX = 2 + private const val INDEX_NOT_FOUND = -1 + private const val IDENTITY_EVENT_BUFFER_CAPACITY = 1 + private const val EXPIRY_TRANSITION_OFFSET_MS = 1L // BLE, Wi-Fi Aware, and Noise services each hold their own manager // instance over the same encrypted preferences. Serialize pin updates // process-wide so concurrent promotions cannot lose one another or // race a panic wipe. - private val privateMediaPinsLock = Any() - private var privateMediaPinsEpoch = 0L + private val identityPersistenceLock = Any() + private var identityPersistenceEpoch = 0L + private val identityChanges = + MutableSharedFlow(extraBufferCapacity = IDENTITY_EVENT_BUFFER_CAPACITY) + val changes = identityChanges.asSharedFlow() } private val prefs: SharedPreferences private val lock = Any() - private var privateMediaPinsEpochAtCreation: Long + private var identityPersistenceEpochAtCreation: Long constructor(context: Context) { - privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { - privateMediaPinsEpoch + identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) { + identityPersistenceEpoch } // Create master key for encryption val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) @@ -72,8 +91,8 @@ class SecureIdentityStateManager { /** Test-only storage injection; production always uses encrypted prefs. */ internal constructor(prefs: SharedPreferences, testOnly: Boolean) { require(testOnly) { "Plain SharedPreferences are test-only" } - privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { - privateMediaPinsEpoch + identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) { + identityPersistenceEpoch } this.prefs = prefs } @@ -223,19 +242,197 @@ class SecureIdentityStateManager { return getVerifiedFingerprints().contains(fingerprint) } + @SuppressLint("UseKtx") fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) { if (!isValidFingerprint(fingerprint)) return - synchronized(lock) { - val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + val normalizedFingerprint = fingerprint.lowercase() + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return + val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet()) + ?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf() if (verified) { - current.add(fingerprint) + current.add(normalizedFingerprint) } else { - current.remove(fingerprint) + current.remove(normalizedFingerprint) } - prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } + val verifiedAt = readTimestampMap(KEY_VERIFIED_AT).toMutableMap() + if (verified) verifiedAt[normalizedFingerprint] = System.currentTimeMillis() + else verifiedAt.remove(normalizedFingerprint) + val committed = prefs.edit() + .putStringSet(KEY_VERIFIED_FINGERPRINTS, current) + .putStringSet(KEY_VERIFIED_AT, encodeTimestampMap(verifiedAt)) + .commit() + if (!committed) return + identityChanges.tryEmit(Unit) } } + data class VouchRecord( + val voucherFingerprint: String, + val voucheeSigningKeyHex: String, + val timestampMs: Long + ) + + @SuppressLint("UseKtx") + fun recordVouch( + voucheeFingerprint: String, + voucherFingerprint: String, + voucheeSigningKey: ByteArray, + timestampMs: Long, + nowMs: Long = System.currentTimeMillis() + ): Boolean { + val vouchee = voucheeFingerprint.lowercase() + val voucher = voucherFingerprint.lowercase() + if (!isValidFingerprint(vouchee) || !isValidFingerprint(voucher) || + voucheeSigningKey.size != VouchAttestation.SIGNING_KEY_SIZE + ) return false + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return false + val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() } + val age = nowMs - timestampMs + if (vouchee == voucher || voucher !in verified || vouchee in verified || + age > VouchAttestation.MAX_AGE_MS || + age < -VouchAttestation.MAX_CLOCK_SKEW_MS + ) return false + + val all = readVouchRecords().toMutableMap() + val records = all[vouchee].orEmpty().toMutableList() + val existing = records.indexOfFirst { it.voucherFingerprint == voucher } + val proposed = VouchRecord( + voucherFingerprint = voucher, + voucheeSigningKeyHex = voucheeSigningKey.hexEncodedString(), + timestampMs = timestampMs + ) + val record = records.getOrNull(existing) + ?.takeIf { it.timestampMs >= timestampMs } + ?: proposed + if (existing > INDEX_NOT_FOUND) records[existing] = record else records += record + val capped = records.sortedByDescending { it.timestampMs }.take(MAX_VOUCHERS_PER_VOUCHEE) + if (capped.none { it.voucherFingerprint == voucher }) return false + all[vouchee] = capped + val committed = prefs.edit() + .putStringSet(KEY_VOUCH_RECORDS, encodeVouchRecords(all)) + .commit() + if (!committed) return false + identityChanges.tryEmit(Unit) + return true + } + } + + fun validVouchers( + fingerprint: String, + nowMs: Long = System.currentTimeMillis() + ): List { + val normalized = fingerprint.lowercase() + if (!isValidFingerprint(normalized)) return emptyList() + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return emptyList() + val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() } + val authenticatedSigningKeyHex = getAuthenticatedSigningKey(normalized) + ?.hexEncodedString() ?: return emptyList() + return readVouchRecords()[normalized].orEmpty().filter { + it.voucherFingerprint != normalized && + it.voucherFingerprint in verified && + it.voucheeSigningKeyHex == authenticatedSigningKeyHex && + nowMs - it.timestampMs <= VouchAttestation.MAX_AGE_MS && + nowMs - it.timestampMs >= -VouchAttestation.MAX_CLOCK_SKEW_MS + } + } + } + + fun isVouched(fingerprint: String, nowMs: Long = System.currentTimeMillis()): Boolean { + val normalized = fingerprint.lowercase() + val isExplicitlyVerified = getVerifiedFingerprints().any { + it.equals(normalized, ignoreCase = true) + } + return !isExplicitlyVerified && validVouchers(normalized, nowMs).isNotEmpty() + } + + fun getVouchedFingerprints(nowMs: Long = System.currentTimeMillis()): Set = + synchronized(identityPersistenceLock) { + readVouchRecords().keys.filterTo(mutableSetOf()) { isVouched(it, nowMs) } + } + + fun nextVouchExpiryMs(nowMs: Long = System.currentTimeMillis()): Long? = + synchronized(identityPersistenceLock) { + readVouchRecords().keys + .flatMap { validVouchers(it, nowMs) } + .minOfOrNull { + it.timestampMs + + VouchAttestation.MAX_AGE_MS + + EXPIRY_TRANSITION_OFFSET_MS + } + } + + fun mostRecentlyVerifiedFingerprints(limit: Int, excluding: String): List { + return synchronized(identityPersistenceLock) { + val verifiedAt = readTimestampMap(KEY_VERIFIED_AT) + getVerifiedFingerprints() + .map { it.lowercase() } + .filterNot { it == excluding.lowercase() } + .sortedWith(compareByDescending { verifiedAt[it] ?: Long.MIN_VALUE }.thenByDescending { it }) + .take(limit) + } + } + + fun lastVouchBatchSent(fingerprint: String): Long? = + synchronized(identityPersistenceLock) { + readTimestampMap(KEY_VOUCH_BATCH_SENT_AT)[fingerprint.lowercase()] + } + + @SuppressLint("UseKtx") + fun markVouchBatchSent(fingerprint: String, timestampMs: Long) { + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return + val sent = readTimestampMap(KEY_VOUCH_BATCH_SENT_AT).toMutableMap() + sent[fingerprint.lowercase()] = timestampMs + if (!prefs.edit() + .putStringSet(KEY_VOUCH_BATCH_SENT_AT, encodeTimestampMap(sent)) + .commit() + ) { + Log.e(TAG, "Vouch batch timestamp could not be committed") + } + } + } + + private fun readTimestampMap(key: String): Map = + prefs.getStringSet(key, emptySet()).orEmpty().mapNotNull { entry -> + val split = entry.lastIndexOf(RECORD_SEPARATOR) + if (split <= VOUCHEE_FIELD_INDEX) { + null + } else { + entry.substring(split + RECORD_SEPARATOR_LENGTH).toLongOrNull()?.let { + entry.substring(VOUCHEE_FIELD_INDEX, split) to it + } + } + }.toMap() + + private fun encodeTimestampMap(values: Map): Set = + values.mapTo(mutableSetOf()) { (fingerprint, timestamp) -> + "$fingerprint$RECORD_SEPARATOR$timestamp" + } + + private fun readVouchRecords(): Map> = + prefs.getStringSet(KEY_VOUCH_RECORDS, emptySet()).orEmpty().mapNotNull { entry -> + val fields = entry.split(RECORD_SEPARATOR) + if (fields.size != VOUCH_RECORD_FIELD_COUNT) null else fields.last().toLongOrNull()?.let { + fields[VOUCHEE_FIELD_INDEX] to VouchRecord( + voucherFingerprint = fields[VOUCHER_FIELD_INDEX], + voucheeSigningKeyHex = fields[VOUCHEE_SIGNING_KEY_FIELD_INDEX], + timestampMs = it + ) + } + }.groupBy({ it.first }, { it.second }) + + private fun encodeVouchRecords(values: Map>): Set = + values.flatMapTo(mutableSetOf()) { (vouchee, records) -> + records.map { + "$vouchee$RECORD_SEPARATOR${it.voucherFingerprint}" + + "$RECORD_SEPARATOR${it.voucheeSigningKeyHex}" + + "$RECORD_SEPARATOR${it.timestampMs}" + } + } + fun getCachedPeerFingerprint(peerID: String): String? { val pid = peerID.lowercase() // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility @@ -323,8 +520,8 @@ class SecureIdentityStateManager { fun isPrivateMediaCapable(fingerprint: String): Boolean { if (!isValidFingerprint(fingerprint)) return false - return synchronized(privateMediaPinsLock) { - if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) { + return synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) { return@synchronized false } prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet()) @@ -339,11 +536,13 @@ class SecureIdentityStateManager { state: AuthenticatedPeerState, onCommitted: () -> Unit = {} ): Boolean { - if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false + if (!isValidFingerprint(fingerprint) || + state.signingPublicKey.size != VouchAttestation.SIGNING_KEY_SIZE + ) return false val normalizedFingerprint = fingerprint.lowercase() - return synchronized(privateMediaPinsLock) { + return synchronized(identityPersistenceLock) { // A controller that survived panic must not republish pre-wipe proof state. - if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized false val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?.toMutableSet() ?: mutableSetOf() records.removeAll { it.startsWith("$normalizedFingerprint:") } @@ -362,15 +561,18 @@ class SecureIdentityStateManager { // This result is a security boundary: do not publish the Ed key in memory unless the // encrypted identity record and its HSTS pin were durably committed together. editor.commit().also { committed -> - if (committed) onCommitted() + if (committed) { + identityChanges.tryEmit(Unit) + onCommitted() + } } } } fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? { if (!isValidFingerprint(fingerprint)) return null - return synchronized(privateMediaPinsLock) { - if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null + return synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized null val prefix = "${fingerprint.lowercase()}:" val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null @@ -466,21 +668,18 @@ class SecureIdentityStateManager { * Clear all identity data (for panic mode) */ @SuppressLint("UseKtx") - fun clearIdentityData() { - try { - synchronized(privateMediaPinsLock) { - privateMediaPinsEpoch += 1 - privateMediaPinsEpochAtCreation = privateMediaPinsEpoch - if (!prefs.edit().clear().commit()) { - Log.e(TAG, "Identity preference wipe could not be committed") - } - } - Log.w(TAG, "All identity data cleared") - } catch (e: Exception) { - Log.e(TAG, "Failed to clear identity data: ${e.message}") + fun clearIdentityData(): Boolean = try { + synchronized(identityPersistenceLock) { + identityPersistenceEpoch += 1 + identityPersistenceEpochAtCreation = identityPersistenceEpoch + check(prefs.edit().clear().commit()) { "Identity preference wipe could not be committed" } + identityChanges.tryEmit(Unit) } + true + } catch (_: Exception) { + false } - + /** * Check if identity data exists */ @@ -493,6 +692,10 @@ class SecureIdentityStateManager { /** * Store a string value in secure preferences */ + /** Persist keys and consumption state before making their use observable. */ + fun storeSecureValueSynchronously(key: String, value: String): Boolean = + prefs.edit().putString(key, value).commit() + fun storeSecureValue(key: String, value: String) { prefs.edit().putString(key, value).apply() } diff --git a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt index 279fe527..d9276151 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt @@ -11,7 +11,7 @@ import com.bitchat.android.protocol.MessageType object BLEPacketPaddingPolicy { fun shouldPadForBLE(type: UByte): Boolean { return when (MessageType.fromValue(type)) { - MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true + MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE, MessageType.COURIER_ENVELOPE -> true else -> false } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 0e4f29af..81e80b68 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -367,6 +367,26 @@ class BluetoothConnectionManager( ) } + suspend fun sendToPeerAndAwaitAcceptance(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToPeerAndAwaitAcceptance( + routed, + peerID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + + suspend fun sendToPeerAndAwaitCompletion(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToPeerAndAwaitCompletion( + routed, + peerID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + fun cancelTransfer(transferId: String): Boolean { return packetBroadcaster.cancelTransfer(transferId) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 22f17867..6b7f1557 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -2,13 +2,13 @@ package com.bitchat.android.mesh import android.content.Context import android.util.Log +import com.bitchat.android.board.transportSenderID import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket -import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket @@ -19,6 +19,8 @@ import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import java.util.* @@ -49,10 +51,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Core components - each handling specific responsibilities private val encryptionService = EncryptionService(context) + private val courierStore = CourierStore(context) + private val bridgeCourierService by lazy { + com.bitchat.android.nostr.BridgeCourierService(context, encryptionService) { envelope -> + handleLocalCourierEnvelope(envelope) + } + } // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val peerManager = PeerManager() + private val identityState = SecureIdentityStateManager(context.applicationContext) private val fragmentManager = FragmentManager() private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope) @@ -65,13 +74,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic store = authenticatedPeerStateStore, localStateProvider = { AuthenticatedPeerState( - PeerCapabilities.LOCAL_SUPPORTED, + PeerCapabilities.localSupported(), requireNotNull(encryptionService.getSigningPublicKey()) ) }, applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, sendState = ::sendAuthenticatedPeerState, - onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID) + delegate?.didResolvePrivateMediaPolicy(peerID) } ) } private val privateMediaSecurity by lazy { PrivateMediaSecurityController( @@ -109,11 +119,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() + private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context) private val messageHandler = MessageHandler(myPeerID, context.applicationContext) internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) private val voiceFrameQueue = Channel(capacity = 128) + private val meshPingManager = MeshPingManager(myPeerID, serviceScope) { packet -> + broadcastRoutedPacket(RoutedPacket(packet)) + } private lateinit var gossipSyncManager: GossipSyncManager // Service-level notification manager for background (no-UI) DMs private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( @@ -130,6 +144,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Coroutines // Tracks whether this instance has been terminated via stopServices() private var terminated = false + private val vouchCoordinator by lazy { + VouchCoordinator( + scope = serviceScope, + identity = identityState, + connectedPeerIDs = peerManager::getActivePeerIDs, + fingerprintForPeer = peerManager::getFingerprintForPeer, + peerInfo = peerManager::getPeerInfo, + signingKeyForFingerprint = ::signingKeyForFingerprint, + hasEstablishedSession = encryptionService::hasEstablishedSession, + sign = encryptionService::signData, + verify = encryptionService::verifyEd25519Signature, + send = ::sendVouchPayload + ) + } init { serviceScope.launch { @@ -156,10 +184,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic gossipSyncManager = GossipSyncManager( myPeerID = myPeerID, scope = serviceScope, + context = context, configProvider = object : GossipSyncManager.ConfigProvider { override fun seenCapacity(): Int = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) - } catch (_: Exception) { 500 } + com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000) + } catch (_: Exception) { 1000 } override fun gcsMaxBytes(): Int = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) @@ -170,6 +199,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } catch (_: Exception) { 0.01 } } ) + gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> signPacketBeforeBroadcast(packet) @@ -177,6 +207,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic if (isBleTransportEnabled()) { TransportBridgeService.register("BLE", this) } + serviceScope.launch { + com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collect { enabled -> + if (enabled) bridgeCourierService.start() else bridgeCourierService.stop() + } + } // Inject dynamic direct connection check into PeerManager // Matches iOS logic: checks if we have an active hardware mapping for this peer @@ -200,6 +235,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic connectionManager.sendPacketToPeer(peerID, packet) } + override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean { + if (!isBleTransportEnabled()) return false + return connectionManager.sendPacketToPeer(peerID, packet) + } + private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean { if (!isBleTransportEnabled()) return false val queued = connectionManager.broadcastPacket(routed) @@ -259,6 +299,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onPeerListUpdated(peerIDs: List) { // Update process-wide state first try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { } + vouchCoordinator.peersUpdated(peerIDs) // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -291,6 +332,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey, authenticatedSessionToken ) + vouchCoordinator.peerAuthenticated( + peerID, + identityState.generateFingerprint(authenticatedRemoteStaticKey) + ) // Send announcement and cached messages after key exchange serviceScope.launch { delay(100) @@ -396,7 +441,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun getBroadcastRecipient(): ByteArray { return SpecialRecipients.BROADCAST } - + // Cryptographic operations override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { return securityManager.verifySignature(packet, peerID) @@ -514,6 +559,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID) + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } // Status events can arrive while MainActivity has detached the UI delegate. // Persist first so the next UI collector observes the advancement. try { @@ -526,6 +573,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onReadReceiptReceived(messageID: String, peerID: String) { + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID) + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -542,6 +591,30 @@ 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 + ) { + GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupKeyUpdateReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { + GroupMessagePort.receiver?.message(payload, timestampMs) + } + + override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) { + vouchCoordinator.handlePayload(peerID, payload) + } } // PacketProcessor delegates @@ -566,6 +639,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun getBroadcastRecipient(): ByteArray { return SpecialRecipients.BROADCAST } + + override fun isPeerDirectlyConnected(peerID: String): Boolean = + peerManager.getPeerInfo(peerID)?.isDirectConnection == true override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { return runBlocking { securityManager.handleNoiseHandshake(routed) } @@ -574,6 +650,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } + + override fun handleCourierEnvelope(routed: RoutedPacket): Boolean { + return handleCourierEnvelopePacket(routed) + } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { val result = messageHandler.handleAnnounceWithResult(routed) @@ -599,6 +679,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + handleCourierAnnounce(routed) return true } @@ -608,7 +689,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic try { val pkt = routed.packet val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) - if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) { gossipSyncManager.onPublicPacketSeen(pkt) } } catch (_: Exception) { } @@ -616,6 +697,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) } @@ -656,6 +741,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val req = RequestSyncPacket.decode(routed.packet.payload) ?: return gossipSyncManager.handleRequestSync(fromPeer, req) } + + override fun handleBoardPost(routed: RoutedPacket): Boolean { + val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload) + ?: return false + return boardStore.ingestRemoteForRelay(wire, routed.packet) + } + override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed) + + override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed) } // BluetoothConnectionManager delegates @@ -746,6 +840,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } + fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) { + meshPingManager.ping(peerID, callback) + } + /** * Start the mesh service */ @@ -809,6 +907,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic * Stop all mesh services */ fun stopServices() { + if (bridgeCourierService.isStarted) bridgeCourierService.stop() if (!isActive) { Log.w(TAG, "Mesh service not active, ignoring stop request") return @@ -857,6 +956,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic */ fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { if (content.isEmpty()) return + val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy() serviceScope.launch { val packet = BitchatPacket( @@ -875,6 +975,84 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic broadcastRoutedPacket(RoutedPacket(signedPacket)) // Track our own broadcast message for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + if (channel == null) { + val nickname = runCatching { + com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) + }.getOrNull() + BridgeMeshPort.bridgeOutgoing( + content, + myPeerID, + packet.timestamp.toLong(), + nickname, + bridgePolicyAtSend + ) + } + } + } + + fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) { + sendRawProtocolPacket(MessageType.NOSTR_CARRIER, payload, recipientPeerID, sign = true) + } + + fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) { + sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true) + } + + fun sendPrekeyBundle(payload: ByteArray) { + sendRawProtocolPacket(MessageType.PREKEY_BUNDLE, payload, null, sign = true) + } + + private fun sendRawProtocolPacket( + type: MessageType, + payload: ByteArray, + recipientPeerID: String?, + sign: Boolean + ) { + if (payload.isEmpty()) return + serviceScope.launch { + val packet = BridgeProtocolPacketFactory.protocolPacket( + type = type, + payload = payload, + senderPeerId = myPeerID, + recipientPeerId = recipientPeerID, + ttl = MAX_TTL + ) ?: return@launch + val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet + if (sign && + type != MessageType.COURIER_ENVELOPE && + outgoing.signature?.size != 64 + ) { + return@launch + } + gossipSyncManager.onPublicPacketSeen(outgoing) + broadcastRoutedPacket(RoutedPacket(outgoing)) + } + } + + + + fun sendBoardPayload(payload: ByteArray) { + val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return + if (!wire.verifySignature()) return + serviceScope.launch { + // The inner board signature is authoritative. A stable outer + // sender/signature would re-link otherwise isolated location scopes. + val packet = BitchatPacket( + version = 1u, + type = MessageType.BOARD_POST.value, + senderID = wire.transportSenderID(), + recipientID = null, + timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + boardStore.ingest( + wire, + packet, + com.bitchat.android.board.BoardIngestSource.LOCAL + ) + broadcastRoutedPacket(RoutedPacket(packet)) } } @@ -907,6 +1085,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return true } + private fun signingKeyForFingerprint(fingerprint: String): ByteArray? { + identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it } + return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID -> + val peerFingerprint = peerManager.getFingerprintForPeer(peerID) + peerManager.getPeerInfo(peerID)?.signingPublicKey + ?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) } + } + } + + private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean { + val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode() + val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false + val packet = BitchatPacket( + version = VouchCoordinator.NOISE_PACKET_VERSION, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + ttl = MAX_TTL + ) + broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + return true + } + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { try { val payload = file.encode() @@ -937,6 +1140,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } /** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */ + fun supportsPrivateMediaReceipts(peerID: String): Boolean { + val session = encryptionService.getAuthenticatedSession(peerID) ?: return false + val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false + return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS) + } + fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { val payload = file.encode() ?: return when (val prepared = prepareFilePrivate( @@ -1206,6 +1415,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 { @@ -1251,7 +1496,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( + nickname, + staticKey, + signingKey + ) var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") @@ -1313,7 +1562,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( + nickname, + staticKey, + signingKey + ) var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") @@ -1442,6 +1695,218 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return peerManager.getPeerInfo(peerID) } + fun getPeerInfos(): List = peerManager.getAllPeerNicknames().keys.mapNotNull(peerManager::getPeerInfo) + + fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List { + val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return emptyList() + val typedPayload = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + privateMessage + ).encode() + val sealed = try { com.bitchat.android.services.bridge.PrekeyManager.getInstance(context).seal(typedPayload, messageID, recipientNoiseKey, true) } catch (_: Exception) { return emptyList() } + val now = System.currentTimeMillis() + val couriers = courierPeerIDs.distinct().take(4) + if (couriers.isEmpty()) return emptyList() + return couriers.filter { courierID -> + val envelope = com.bitchat.android.model.CourierEnvelope( + recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag( + recipientNoiseKey, + com.bitchat.android.model.CourierEnvelope.epochDay(now) + ), + expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed.ciphertext, + prekeyID = sealed.prekeyId?.toUInt(), + copies = 4u + ) + val payload = envelope.encode() ?: return@filter false + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(courierID), + timestamp = now.toULong(), + payload = payload, + ttl = MAX_TTL + ) + TransportBridgeService.sendToPeerFromLocalAndReport(courierID, signPacketBeforeBroadcast(packet)) + } + } + + fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean = bridgeCourierService.deposit(content, messageID, recipientNoiseKey, onAccepted) + + private fun handleLocalCourierEnvelope(envelope: com.bitchat.android.model.CourierEnvelope) { + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = ByteArray(8), + recipientID = hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return, + ttl = MAX_TTL + ) + handleCourierEnvelopePacket(RoutedPacket(packet, peerID = "bridge")) + } + + private fun handleCourierEnvelopePacket(routed: RoutedPacket): Boolean { + val envelope = com.bitchat.android.model.CourierEnvelope.decode(routed.packet.payload) ?: return false + val now = System.currentTimeMillis() + if (envelope.expiry.toLong() <= now) return false + val localKey = encryptionService.getStaticPublicKey() ?: return false + if (envelope.matchesRecipient(localKey, now)) { + val opened = try { + com.bitchat.android.services.bridge.PrekeyManager.getInstance(context) + .open(envelope.ciphertext, envelope.prekeyID?.toLong()) + } catch (_: Exception) { return false } + val senderKey = opened.senderStaticKey + val typedPayload = opened.payload + if (opened.consumedPrekey) { + com.bitchat.android.services.bridge.MeshBridgeService.refreshPrekeys() + } + val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false + if (noisePayload.type !in setOf( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + com.bitchat.android.model.NoisePayloadType.DELIVERED + ) + ) return false + val senderPeerID = com.bitchat.android.services.ContactIdentityResolver.peerIdForNoiseKey(senderKey) + val synthetic = routed.copy( + packet = routed.packet.copy( + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(senderPeerID), + payload = typedPayload, + timestamp = System.currentTimeMillis().toULong() + ), + peerID = senderPeerID + ) + val delivered = runBlocking { messageHandler.handleOpenedCourierPayload(synthetic) } + if (delivered && noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { + val messageID = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)?.messageID + if (messageID != null) sendCourierDeliveryAck(messageID, senderKey, routed) + } + return delivered + } + val peerID = routed.peerID ?: return false + if (!DirectCourierDepositPolicy.accepts( + routed, + MAX_TTL, + connectionManager::getCurrentLinkID, + connectionManager.addressPeerMap::get + ) + ) return false + val depositor = peerManager.getPeerInfo(peerID) ?: return false + val key = depositor.noisePublicKey ?: return false + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(key)?.isMutual == true + } catch (_: Exception) { false } + val tier = if (favorite) CourierDepositTier.FAVORITE + else if (depositor.hasVerifiedAnnouncement) CourierDepositTier.VERIFIED + else return false + return courierStore.deposit(envelope, key, tier) + } + + private fun sendCourierDeliveryAck(messageID: String, senderNoiseKey: ByteArray, ingress: RoutedPacket) { + val typedPayload = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.DELIVERED, + messageID.toByteArray(Charsets.UTF_8) + ).encode() + if (ingress.peerID == "bridge") { + bridgeCourierService.depositPayload(typedPayload, senderNoiseKey) + return + } + val courierPeerID = ingress.peerID ?: return + val now = System.currentTimeMillis() + val sealed = try { encryptionService.sealCourierPayload(typedPayload, senderNoiseKey) } catch (_: Exception) { return } + val envelope = com.bitchat.android.model.CourierEnvelope( + recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag( + senderNoiseKey, + com.bitchat.android.model.CourierEnvelope.epochDay(now) + ), + expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 4u + ) + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(courierPeerID), + timestamp = now.toULong(), + payload = envelope.encode() ?: return, + ttl = MAX_TTL + ) + TransportBridgeService.sendToPeerFromLocalAndReport(courierPeerID, signPacketBeforeBroadcast(packet)) + } + + private fun handleCourierAnnounce(routed: RoutedPacket) { + val peerID = routed.peerID ?: return + val info = peerManager.getPeerInfo(peerID) ?: return + val noiseKey = info.noisePublicKey ?: return + val direct = routed.packet.ttl >= MAX_TTL + val envelopes = if (direct) courierStore.copiesForRecipient(noiseKey) else courierStore.copiesForRemoteHandover(noiseKey) + envelopes.forEach { envelope -> + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return@forEach, + ttl = MAX_TTL + ) + if (direct) { + serviceScope.launch { + if (connectionManager.sendToPeerAndAwaitCompletion( + peerID, + RoutedPacket(signPacketBeforeBroadcast(packet)) + ) + ) { + courierStore.remove(envelope) + } + } + } else TransportBridgeService.broadcastFromLocal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + if (direct) { + courierStore.sprayCopiesFor(noiseKey).forEach { envelope -> + val payload = envelope.encode() + if (payload == null) { + courierStore.cancelSpray(envelope, noiseKey) + return@forEach + } + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = MAX_TTL + ) + serviceScope.launch { + var committed = false + try { + if (connectionManager.sendToPeerAndAwaitCompletion( + peerID, + RoutedPacket(signPacketBeforeBroadcast(packet)) + ) + ) { + committed = courierStore.commitSpray(envelope, noiseKey) + } + } finally { + if (!committed) courierStore.cancelSpray(envelope, noiseKey) + } + }.invokeOnCompletion { + // A coroutine cancelled before its body starts never reaches finally. + courierStore.cancelSpray(envelope, noiseKey) + } + } + } + } + /** * Update peer information with verification data */ @@ -1465,6 +1930,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 @@ -1620,20 +2091,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic */ fun clearAllInternalData() { Log.w(TAG, "Clearing all mesh service internal data") - try { - // Stop services to cease broadcasting old ID immediately - stopServices() - - // Clear all managers - fragmentManager.clearAllFragments() - storeForwardManager.clearAllCache() - securityManager.clearAllData() - peerManager.clearAllPeers() - peerManager.clearAllFingerprints() - try { gossipSyncManager.clear() } catch (_: Exception) { } - } catch (e: Exception) { - Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") + val operations = listOf<() -> Unit>( + ::stopServices, + fragmentManager::clearAllFragments, + storeForwardManager::clearAllCache, + securityManager::clearAllData, + peerManager::clearAllPeers, + peerManager::clearAllFingerprints, + courierStore::wipe, + { boardStore.wipe() }, + bridgeCourierService::stop, + gossipSyncManager::clear + ) + var failure: Exception? = null + operations.forEach { operation -> + try { operation() } catch (error: Exception) { failure = error } } + failure?.let { throw IllegalStateException("Mesh data wipe incomplete", it) } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 9ff9856d..a8fb804c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.channels.actor import java.util.ArrayDeque @@ -55,6 +56,7 @@ class BluetoothPacketBroadcaster( private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576 private const val SEND_RETRY_DELAY_MS = 15L private const val MAX_CALLBACK_RETRIES = 3 + private const val SEND_COMPLETION_TIMEOUT_MS = 30_000L } // Optional nickname resolver injected by higher layer (peerID -> nickname?) @@ -140,6 +142,7 @@ class BluetoothPacketBroadcaster( val gatt: BluetoothGatt? = null, val gattServer: BluetoothGattServer? = null, val characteristic: BluetoothGattCharacteristic, + val completion: CompletableDeferred? = null, var callbackFailures: Int = 0 ) @@ -204,6 +207,30 @@ class BluetoothPacketBroadcaster( } } + suspend fun sendPacketToPeerAndAwaitAcceptance( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + + suspend fun sendPacketToPeerAndAwaitCompletion( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeerAndAwaitCompletion(packet, targetPeerID, gattServer, characteristic) + } + } + fun sendPacketToLink( routed: RoutedPacket, deviceAddress: String, @@ -267,6 +294,35 @@ class BluetoothPacketBroadcaster( return false } + private suspend fun sendSinglePacketToPeerAndAwaitCompletion( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + val packet = routed.packet + val data = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + ) ?: return false + val completion = CompletableDeferred() + + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } + val queuedOnServer = serverTarget != null && + notifyDevice(serverTarget, data, gattServer, characteristic, completion) + val queued = if (queuedOnServer) { + true + } else { + val clientTarget = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } + ?: return false + writeToDeviceConn(clientTarget, data, completion) + } + if (!queued) return false + + return withTimeoutOrNull(SEND_COMPLETION_TIMEOUT_MS) { completion.await() } ?: false + } + /** * Public entry point for broadcasting - submits request to actor for serialization @@ -474,7 +530,8 @@ class BluetoothPacketBroadcaster( device: BluetoothDevice, data: ByteArray, gattServer: BluetoothGattServer?, - characteristic: BluetoothGattCharacteristic? + characteristic: BluetoothGattCharacteristic?, + completion: CompletableDeferred? = null ): Boolean { val server = gattServer ?: return false val char = characteristic ?: return false @@ -484,7 +541,13 @@ class BluetoothPacketBroadcaster( ?: return false return enqueueSend( SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION), - PendingSend(data.copyOf(), device, gattServer = server, characteristic = char) + PendingSend( + data.copyOf(), + device, + gattServer = server, + characteristic = char, + completion = completion + ) ) } @@ -493,13 +556,20 @@ class BluetoothPacketBroadcaster( */ private fun writeToDeviceConn( deviceConn: BluetoothConnectionTracker.DeviceConnection, - data: ByteArray + data: ByteArray, + completion: CompletableDeferred? = null ): Boolean { val gatt = deviceConn.gatt ?: return false val char = deviceConn.characteristic ?: return false return enqueueSend( SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE), - PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char) + PendingSend( + data.copyOf(), + deviceConn.device, + gatt = gatt, + characteristic = char, + completion = completion + ) ) } @@ -631,6 +701,7 @@ class BluetoothPacketBroadcaster( } state.pending.removeFirst() state.pendingBytes -= head.data.size + head.completion?.complete(status == BluetoothGatt.GATT_SUCCESS) if (state.pending.isEmpty()) { sendStates.remove(key) false @@ -645,8 +716,13 @@ class BluetoothPacketBroadcaster( fun onLinkDisconnected(deviceAddress: String, linkID: String?) { synchronized(sendLock) { - sendStates.keys.removeAll { key -> - key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID) + val iterator = sendStates.entries.iterator() + while (iterator.hasNext()) { + val (key, state) = iterator.next() + if (key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)) { + state.pending.forEach { it.completion?.complete(false) } + iterator.remove() + } } } } @@ -667,7 +743,12 @@ class BluetoothPacketBroadcaster( * Shutdown the broadcaster actor gracefully */ fun shutdown() { - synchronized(sendLock) { sendStates.clear() } + synchronized(sendLock) { + sendStates.values.forEach { state -> + state.pending.forEach { it.completion?.complete(false) } + } + sendStates.clear() + } // Close the actor gracefully broadcasterActor.close() diff --git a/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt new file mode 100644 index 00000000..f803aeef --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt @@ -0,0 +1,98 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket + +/** + * Immutable privacy decision captured when a public message is accepted. + * + * A later opt-in must not authorize a message that was composed while + * bridging was disabled or nearby-only. Publication therefore requires both + * this send-time decision and the current policy to allow bridging. + */ +class BridgeOutboundPolicy internal constructor( + internal val enabled: Boolean, + internal val nearbyOnly: Boolean +) { + val allowsBridging: Boolean + get() = enabled && !nearbyOnly + + fun permitsPublication(current: BridgeOutboundPolicy): Boolean = + allowsBridging && current.allowsBridging + + companion object { + val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false) + + internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy = + BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly) + } +} + +/** + * Transport-facing bridge boundary. + * + * BLE/Wi-Fi packet code depends only on this protocol surface; application + * bootstrap installs the process bridge controller. Tests can install a fake + * without constructing relay or persistence infrastructure. + */ +interface BridgeMeshDelegate { + fun advertisedCell(): String? + fun outboundPolicy(): BridgeOutboundPolicy + + fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String?, + policyAtSend: BridgeOutboundPolicy + ) + + fun handleAuthenticatedRadioMessage(messageId: String) + fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) + fun handlePrekeyPacket(packet: BitchatPacket) + fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) +} + +object BridgeMeshPort : BridgeMeshDelegate { + @Volatile + private var delegate: BridgeMeshDelegate? = null + + fun install(delegate: BridgeMeshDelegate) { + this.delegate = delegate + } + + override fun advertisedCell(): String? = delegate?.advertisedCell() + + override fun outboundPolicy(): BridgeOutboundPolicy = + delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied + + override fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String?, + policyAtSend: BridgeOutboundPolicy + ) { + delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend) + } + + override fun handleAuthenticatedRadioMessage(messageId: String) { + delegate?.handleAuthenticatedRadioMessage(messageId) + } + + override fun handleVerifiedAnnouncement( + peerId: String, + announcement: IdentityAnnouncement + ) { + delegate?.handleVerifiedAnnouncement(peerId, announcement) + } + + override fun handlePrekeyPacket(packet: BitchatPacket) { + delegate?.handlePrekeyPacket(packet) + } + + override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { + delegate?.handleCarrier(payload, fromPeerId, directedToUs) + } + +} diff --git a/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt b/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt new file mode 100644 index 00000000..d15d40ed --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt @@ -0,0 +1,241 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.services.AndroidConversationStorageCipher +import com.bitchat.android.services.ConversationStorageCipher +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.util.Base64 +import java.nio.file.StandardCopyOption + +enum class CourierDepositTier { FAVORITE, VERIFIED } + +/** Bounded persistent mailbag for opaque envelopes deposited by other peers. */ +internal class CourierStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS), + private val now: () -> Long = System::currentTimeMillis +) { + private data class Stored( + val encoded: String, + val depositorKey: String, + val tier: CourierDepositTier, + var copies: Int, + val sprayedTo: MutableSet = mutableSetOf(), + var lastRemoteHandoverAtMs: Long = 0 + ) + + private data class SprayReservation( + val envelopeKey: String, + val courierKey: String, + val copies: Int + ) + + companion object { + private const val KEY_ALIAS = "bitchat_courier_store_v1" + private val AAD = "bitchat-courier-store-v1".toByteArray(Charsets.UTF_8) + private const val MAX_ENVELOPES = 40 + private const val MAX_VERIFIED_ENVELOPES = 20 + private const val MAX_PER_FAVORITE = 5 + private const val MAX_PER_VERIFIED = 2 + private const val EXPIRY_SLACK_MS = 60 * 60 * 1000L + private const val REMOTE_HANDOVER_COOLDOWN_MS = 10 * 60 * 1000L + } + + private val gson = Gson() + private val file = File(context.applicationContext.filesDir, "courier-store.sealed") + private val stored = load() + private val sprayReservations = mutableListOf() + + @Synchronized + fun deposit(envelope: CourierEnvelope, depositorNoiseKey: ByteArray, tier: CourierDepositTier): Boolean { + pruneExpired() + val nowMs = now() + if (envelope.expiry.toLong() <= nowMs || + envelope.expiry.toLong() > nowMs + CourierEnvelope.MAX_LIFETIME_MS + EXPIRY_SLACK_MS + ) return false + val encodedBytes = envelope.encode() ?: return false + val encoded = Base64.getEncoder().encodeToString(encodedBytes) + if (stored.any { it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true }) return true + val depositor = depositorNoiseKey.toHex() + val perDepositor = if (tier == CourierDepositTier.FAVORITE) MAX_PER_FAVORITE else MAX_PER_VERIFIED + if (stored.count { it.depositorKey == depositor && it.tier == tier } >= perDepositor) return false + if (tier == CourierDepositTier.VERIFIED && stored.count { it.tier == tier } >= MAX_VERIFIED_ENVELOPES) { + stored.removeAt(stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED }) + } + if (stored.size >= MAX_ENVELOPES) { + val verifiedIndex = stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED } + if (tier == CourierDepositTier.VERIFIED && verifiedIndex < 0) return false + val index = verifiedIndex.takeIf { it >= 0 } ?: 0 + stored.removeAt(index) + } + stored += Stored(encoded, depositor, tier, envelope.copies.toInt()) + persist() + return true + } + + @Synchronized + fun copiesForRecipient(recipientNoiseKey: ByteArray): List { + pruneExpired() + val nowMs = now() + return stored.mapNotNull { record -> + record.envelope() + ?.takeIf { it.matchesRecipient(recipientNoiseKey, nowMs) } + ?.copy(copies = 1u) + } + } + + @Synchronized + fun remove(envelope: CourierEnvelope): Boolean { + val envelopeKey = envelope.ciphertext.storageKey() + val removed = stored.removeAll { + it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true + } + if (removed) { + sprayReservations.removeAll { it.envelopeKey == envelopeKey } + persist() + } + return removed + } + + @Synchronized + fun copiesForRemoteHandover(recipientNoiseKey: ByteArray): List { + pruneExpired() + val nowMs = now() + val result = mutableListOf() + stored.forEach { record -> + val envelope = record.envelope() ?: return@forEach + if (envelope.matchesRecipient(recipientNoiseKey, nowMs) && + nowMs - record.lastRemoteHandoverAtMs >= REMOTE_HANDOVER_COOLDOWN_MS + ) { + record.lastRemoteHandoverAtMs = nowMs + result += envelope.copy(copies = 1u) + } + } + if (result.isNotEmpty()) persist() + return result + } + + @Synchronized + fun sprayCopiesFor(courierNoiseKey: ByteArray): List { + pruneExpired() + val key = courierNoiseKey.toHex() + val nowMs = now() + val courierTags = listOf(-1, 0, 1).map { + CourierEnvelope.recipientTag(courierNoiseKey, CourierEnvelope.epochDay(nowMs) + it.toUInt()) + } + val result = mutableListOf() + stored.forEach { record -> + val envelope = record.envelope() ?: return@forEach + val envelopeKey = envelope.ciphertext.storageKey() + if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo || + sprayReservations.any { it.envelopeKey == envelopeKey && it.courierKey == key } || + courierTags.any { it.contentEquals(envelope.recipientTag) } + ) return@forEach + val reserved = sprayReservations + .filter { it.envelopeKey == envelopeKey } + .sumOf { it.copies } + val available = record.copies - reserved + if (available <= 1) return@forEach + val given = available / 2 + sprayReservations += SprayReservation(envelopeKey, key, given) + result += envelope.copy(copies = given.toUByte()) + } + return result + } + + @Synchronized + fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean { + val key = courierNoiseKey.toHex() + val envelopeKey = envelope.ciphertext.storageKey() + val reservationIndex = sprayReservations.indexOfFirst { + it.envelopeKey == envelopeKey && it.courierKey == key && it.copies == envelope.copies.toInt() + } + if (reservationIndex < 0) return false + val reservation = sprayReservations[reservationIndex] + val record = stored.firstOrNull { + it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true + } + val otherReservedCopies = sprayReservations.withIndex() + .filter { (index, candidate) -> index != reservationIndex && candidate.envelopeKey == envelopeKey } + .sumOf { it.value.copies } + if (record == null || key in record.sprayedTo || + record.copies - otherReservedCopies <= reservation.copies + ) { + sprayReservations.removeAt(reservationIndex) + return false + } + record.copies -= reservation.copies + record.sprayedTo += key + sprayReservations.removeAt(reservationIndex) + persist() + return true + } + + @Synchronized + fun cancelSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean { + val envelopeKey = envelope.ciphertext.storageKey() + val courierKey = courierNoiseKey.toHex() + return sprayReservations.removeAll { + it.envelopeKey == envelopeKey && it.courierKey == courierKey && it.copies == envelope.copies.toInt() + } + } + + @Synchronized + fun wipe() { + stored.clear() + sprayReservations.clear() + file.delete() + cipher.destroyKey() + } + + private fun pruneExpired() { + val nowMs = now().toULong() + if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) { + val retainedEnvelopeKeys = stored.mapNotNull { it.envelope()?.ciphertext?.storageKey() }.toSet() + sprayReservations.removeAll { it.envelopeKey !in retainedEnvelopeKeys } + persist() + } + } + + private fun Stored.envelope(): CourierEnvelope? = try { + CourierEnvelope.decode(Base64.getDecoder().decode(encoded)) + } catch (_: Exception) { null } + + private fun load(): MutableList = try { + if (!file.exists()) return mutableListOf() + val plaintext = cipher.decrypt(file.readBytes(), AAD) + val type = object : TypeToken>() {}.type + gson.fromJson>(plaintext.toString(Charsets.UTF_8), type) ?: mutableListOf() + } catch (_: Exception) { mutableListOf() } + + private fun persist() { + if (stored.isEmpty()) { + file.delete() + return + } + val encrypted = cipher.encrypt(gson.toJson(stored).toByteArray(Charsets.UTF_8), AAD) + val temporary = File(file.parentFile, "${file.name}.tmp") + temporary.writeBytes(encrypted) + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun ByteArray.storageKey(): String = Base64.getEncoder().encodeToString(this) +} diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt new file mode 100644 index 00000000..188872b9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt @@ -0,0 +1,20 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket + +/** Accepts courier custody only from the authenticated peer on the current direct link. */ +internal object DirectCourierDepositPolicy { + fun accepts( + routed: RoutedPacket, + maxTtl: UByte, + currentLinkID: (String) -> String?, + peerForAddress: (String) -> String? + ): Boolean { + val peerID = routed.peerID ?: return false + val relayAddress = routed.relayAddress ?: return false + val ingressLinkID = routed.ingressLinkID ?: return false + return routed.packet.ttl == maxTtl && + currentLinkID(relayAddress) == ingressLinkID && + peerForAddress(relayAddress) == peerID + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 9c6bd15e..adac55c2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -112,6 +112,23 @@ class FragmentingPacketSender( return true } + suspend fun sendAndAwaitAcceptance( + routed: RoutedPacket, + description: String, + sendSingle: suspend (RoutedPacket) -> Boolean + ): Boolean { + val packets = packetsForTransport(routed) ?: return false + Log.d(logTag, "Sending ${packets.size} packet(s) for $description") + for ((index, packet) in packets.withIndex()) { + val accepted = sendSingle( + routed.copy(packet = packet, transferId = null, preparedPackets = null) + ) + if (!accepted) return false + if (index < packets.lastIndex) delay(interFragmentDelayMs) + } + return true + } + fun cancelTransfer(transferId: String): Boolean { val job = transferJobs.remove(transferId) ?: return false job.cancel() diff --git a/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt b/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt new file mode 100644 index 00000000..2ce93a20 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt @@ -0,0 +1,13 @@ +package com.bitchat.android.mesh + +/** Process-level group ingress, independent of Activity and transport lifetimes. */ +interface GroupMessageReceiver { + fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) + fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) + fun message(payload: ByteArray, timestampMs: Long) + fun peerAuthenticated(peerID: String) +} + +object GroupMessagePort { + @Volatile var receiver: GroupMessageReceiver? = null +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 28cd3d02..d4a934ff 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -2,6 +2,7 @@ package com.bitchat.android.mesh import android.content.Context import android.util.Log +import com.bitchat.android.board.transportSenderID import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatFilePacket @@ -16,7 +17,9 @@ import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope @@ -47,6 +50,7 @@ class MeshCore( * Return false to suppress all downstream effects for a rejected message. */ val onMessageReceived: ((BitchatMessage) -> Boolean)? = null, + val onDeliveryReceipt: ((String, String) -> Unit)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, @@ -55,6 +59,7 @@ class MeshCore( ) private val peerManager = PeerManager() + private val identityState = SecureIdentityStateManager(context.applicationContext) val fragmentManager = FragmentManager() private val readReceiptRetrySender = RetryingControlPacketSender(scope) private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) @@ -66,13 +71,14 @@ class MeshCore( store = authenticatedPeerStateStore, localStateProvider = { AuthenticatedPeerState( - PeerCapabilities.LOCAL_SUPPORTED, + PeerCapabilities.localSupported(), requireNotNull(encryptionService.getSigningPublicKey()) ) }, applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, sendState = ::sendAuthenticatedPeerState, - onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID) + delegate?.didResolvePrivateMediaPolicy(peerID) } ) } private val privateMediaSecurity by lazy { PrivateMediaSecurityController( @@ -110,11 +116,27 @@ class MeshCore( } private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() + private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context) private val messageHandler = MessageHandler(myPeerID, context.applicationContext) private val packetProcessor = PacketProcessor(myPeerID) private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) private val voiceFrameQueue = Channel(capacity = 128) private val directPeers = ConcurrentHashMap.newKeySet() + private val vouchCoordinator by lazy { + VouchCoordinator( + scope = scope, + identity = identityState, + connectedPeerIDs = peerManager::getActivePeerIDs, + fingerprintForPeer = peerManager::getFingerprintForPeer, + peerInfo = peerManager::getPeerInfo, + signingKeyForFingerprint = ::signingKeyForFingerprint, + hasEstablishedSession = encryptionService::hasEstablishedSession, + sign = encryptionService::signData, + verify = encryptionService::verifyEd25519Signature, + send = ::sendVouchPayload + ) + } + private val meshPingManager = MeshPingManager(myPeerID, scope, ::dispatchUnsignedDiagnostic) val gossipSyncManager: GossipSyncManager = sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) @@ -129,6 +151,7 @@ class MeshCore( for (request in voiceFrameQueue) dispatchVoiceFrame(request) } messageHandler.packetProcessor = packetProcessor + gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) } setupDelegates() @@ -217,6 +240,7 @@ class MeshCore( peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { } + vouchCoordinator.peersUpdated(peerIDs) delegate?.didUpdatePeerList(peerIDs) } @@ -242,6 +266,10 @@ class MeshCore( authenticatedRemoteStaticKey, authenticatedSessionToken ) + vouchCoordinator.peerAuthenticated( + peerID, + identityState.generateFingerprint(authenticatedRemoteStaticKey) + ) scope.launch { delay(100) sendAnnouncementToPeer(peerID) @@ -411,6 +439,7 @@ class MeshCore( } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + hooks.onDeliveryReceipt?.invoke(messageID, peerID) try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -421,6 +450,7 @@ class MeshCore( } override fun onReadReceiptReceived(messageID: String, peerID: String) { + hooks.onDeliveryReceipt?.invoke(messageID, peerID) try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -437,6 +467,30 @@ 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 + ) { + GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupKeyUpdateReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { + GroupMessagePort.receiver?.message(payload, timestampMs) + } + + override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) { + vouchCoordinator.handlePayload(peerID, payload) + } } packetProcessor.delegate = object : PacketProcessorDelegate { @@ -460,6 +514,9 @@ class MeshCore( return SpecialRecipients.BROADCAST } + override fun isPeerDirectlyConnected(peerID: String): Boolean = + peerManager.getPeerInfo(peerID)?.isDirectConnection == true + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { return runBlocking { securityManager.handleNoiseHandshake(routed) } } @@ -468,6 +525,8 @@ class MeshCore( return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } + override fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false @@ -481,7 +540,7 @@ class MeshCore( try { val pkt = routed.packet val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) - if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) { gossipSyncManager.onPublicPacketSeen(pkt) } } catch (_: Exception) { } @@ -489,6 +548,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) } @@ -527,11 +590,29 @@ class MeshCore( val req = RequestSyncPacket.decode(routed.packet.payload) ?: return gossipSyncManager.handleRequestSync(fromPeer, req) } + + override fun handleBoardPost(routed: RoutedPacket): Boolean { + val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload) + ?: return false + return boardStore.ingestRemoteForRelay(wire, routed.packet) + } + override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed) + + override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed) } } + private fun dispatchUnsignedDiagnostic(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + + fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) { + meshPingManager.ping(peerID, callback) + } + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { if (content.isEmpty()) return + val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy() scope.launch { val packet = BitchatPacket( version = 1u, @@ -546,6 +627,97 @@ class MeshCore( val signedPacket = signPacketBeforeBroadcast(packet) dispatchGlobal(RoutedPacket(signedPacket)) try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + if (channel == null) { + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + BridgeMeshPort.bridgeOutgoing( + content, + myPeerID, + packet.timestamp.toLong(), + nickname, + bridgePolicyAtSend + ) + } + } + } + + fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) { + sendRawProtocolPacket( + type = MessageType.NOSTR_CARRIER, + payload = payload, + recipientPeerID = recipientPeerID, + sign = true + ) + } + + fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) { + sendRawProtocolPacket( + type = MessageType.COURIER_ENVELOPE, + payload = payload, + recipientPeerID = recipientPeerID, + sign = true + ) + } + + fun sendPrekeyBundle(payload: ByteArray) { + sendRawProtocolPacket( + type = MessageType.PREKEY_BUNDLE, + payload = payload, + recipientPeerID = null, + sign = true + ) + } + + private fun sendRawProtocolPacket( + type: MessageType, + payload: ByteArray, + recipientPeerID: String?, + sign: Boolean + ) { + if (payload.isEmpty()) return + scope.launch { + val packet = BridgeProtocolPacketFactory.protocolPacket( + type = type, + payload = payload, + senderPeerId = myPeerID, + recipientPeerId = recipientPeerID, + ttl = maxTtl + ) ?: return@launch + val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet + if (sign && + type != MessageType.COURIER_ENVELOPE && + outgoing.signature?.size != 64 + ) { + return@launch + } + dispatchGlobal(RoutedPacket(outgoing)) + } + } + + + + fun sendBoardPayload(payload: ByteArray) { + val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return + if (!wire.verifySignature()) return + scope.launch { + // The inner board signature is authoritative. A stable outer + // sender/signature would re-link otherwise isolated location scopes. + val packet = BitchatPacket( + version = 1u, + type = MessageType.BOARD_POST.value, + senderID = wire.transportSenderID(), + recipientID = null, + timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + boardStore.ingest( + wire, + packet, + com.bitchat.android.board.BoardIngestSource.LOCAL + ) + dispatchGlobal(RoutedPacket(packet)) } } @@ -575,6 +747,31 @@ class MeshCore( return true } + private fun signingKeyForFingerprint(fingerprint: String): ByteArray? { + identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it } + return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID -> + val peerFingerprint = peerManager.getFingerprintForPeer(peerID) + peerManager.getPeerInfo(peerID)?.signingPublicKey + ?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) } + } + } + + private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean { + val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode() + val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false + val packet = BitchatPacket( + version = VouchCoordinator.NOISE_PACKET_VERSION, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + return true + } + fun sendFileBroadcast(file: BitchatFilePacket) { try { val payload = file.encode() ?: return @@ -658,6 +855,12 @@ class MeshCore( } } + fun supportsPrivateMediaReceipts(peerID: String): Boolean { + val session = encryptionService.getAuthenticatedSession(peerID) ?: return false + val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false + return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS) + } + fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, @@ -798,6 +1001,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 { @@ -832,7 +1067,11 @@ class MeshCore( Log.e("MeshCore", "No signing public key available for announcement") return@launch } - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( + nickname, + staticKey, + signingKey + ) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -853,7 +1092,11 @@ class MeshCore( ?: myPeerID val staticKey = encryptionService.getStaticPublicKey() ?: return val signingKey = encryptionService.getSigningPublicKey() ?: return - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( + nickname, + staticKey, + signingKey + ) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return val packet = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -991,6 +1234,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 = emptyList() @@ -1035,6 +1284,7 @@ class MeshCore( peerManager.clearAllPeers() peerManager.clearAllFingerprints() try { gossipSyncManager.clear() } catch (_: Exception) { } + boardStore.wipe() } fun clearAllEncryptionData() { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index b54b1152..ebd3b74a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -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? diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt new file mode 100644 index 00000000..11e0a48f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MeshDiagnosticsConstants +import com.bitchat.android.protocol.MeshPingPayload +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +data class MeshPingResult(val rttMillis: Long, val hopCount: Int) + +internal class MeshPingManager( + private val myPeerID: String, + private val scope: CoroutineScope, + private val send: (BitchatPacket) -> Unit, +) { + companion object { + /** + * BLE and Wi-Fi Aware have separate manager instances, but a reply can return over either + * transport. Pending probes therefore live at process scope and are namespaced by the + * local identity. + */ + private val pending = ConcurrentHashMap() + } + + private data class Pending( + val peerID: String, + val startedNanos: Long, + val callback: (MeshPingResult?) -> Unit, + val timeout: Job, + ) + + private val inboundByLink = ConcurrentHashMap>() + + fun ping(peerID: String, callback: (MeshPingResult?) -> Unit) { + if (pending.size >= 64) { callback(null); return } + val payload = MeshPingPayload.create(MeshDiagnosticsConstants.TTL) + val key = pendingKey(payload) + val timeout = scope.launch { + delay(MeshDiagnosticsConstants.TIMEOUT_MILLIS) + pending.remove(key)?.callback?.invoke(null) + } + pending[key] = Pending(peerID, System.nanoTime(), callback, timeout) + send(packet(MessageType.PING, peerID, payload)) + } + + fun handlePing(routed: RoutedPacket) { + val packet = routed.packet + val payload = MeshPingPayload.decode(packet.payload) ?: return + val sender = packet.senderID.toHexString() + val ingress = routed.ingressLinkID ?: routed.relayAddress ?: routed.peerID ?: return + if (!consumeInboundBudget(ingress)) return + send(packet(MessageType.PONG, sender, payload)) + } + + fun handlePong(routed: RoutedPacket) { + val payload = MeshPingPayload.decode(routed.packet.payload) ?: return + val key = pendingKey(payload) + val candidate = pending[key] ?: return + if (routed.packet.senderID.toHexString() != candidate.peerID) return + if (!pending.remove(key, candidate)) return + candidate.timeout.cancel() + val elapsed = (System.nanoTime() - candidate.startedNanos) / 1_000_000 + candidate.callback(MeshPingResult(elapsed, payload.hopCount(routed.packet.ttl))) + } + + private fun pendingKey(payload: MeshPingPayload): String = + "$myPeerID:${payload.nonce.toHexString()}" + + private fun consumeInboundBudget(link: String): Boolean { + val now = System.currentTimeMillis() + synchronized(inboundByLink) { + inboundByLink.entries.removeAll { (_, times) -> + synchronized(times) { + times.lastOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS } != false + } + } + if (inboundByLink.size >= 256 && !inboundByLink.containsKey(link)) return false + inboundByLink.putIfAbsent(link, ArrayDeque()) + } + val timestamps = inboundByLink[link] ?: return false + synchronized(timestamps) { + while (timestamps.firstOrNull()?.let { + now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS + } == true + ) { + timestamps.removeFirst() + } + if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false + timestamps.addLast(now) + return true + } + } + + private fun packet(type: MessageType, recipientPeerID: String, payload: MeshPingPayload) = + BitchatPacket( + version = 1u, + type = type.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload.encode(), + signature = null, + ttl = MeshDiagnosticsConstants.TTL, + ) +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 7b357149..f7c1f32d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -13,15 +13,24 @@ interface MeshService { fun stopServices() fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) + fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) + fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) + fun sendPrekeyBundle(payload: ByteArray) fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) + fun supportsPrivateMediaReceipts(peerID: String): Boolean = false + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} 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) + fun sendBoardPayload(payload: ByteArray) {} fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, @@ -32,6 +41,7 @@ interface MeshService { fun sendBroadcastAnnounce() fun sendAnnouncementToPeer(peerID: String) + fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) fun getPeerNicknames(): Map fun getPeerRSSI(): Map @@ -41,6 +51,19 @@ interface MeshService { fun initiateNoiseHandshake(peerID: String) fun getPeerFingerprint(peerID: String): String? fun getPeerInfo(peerID: String): PeerInfo? + fun getPeerInfos(): List = getPeerNicknames().keys.mapNotNull(::getPeerInfo) + fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List = emptyList() + fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean = false fun updatePeerInfo( peerID: String, nickname: String, @@ -50,11 +73,14 @@ 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 fun getDeviceAddressForPeer(peerID: String): String? fun getDeviceAddressToPeerMapping(): Map + fun getDirectBlePeerIDs(): Set = emptySet() fun printDeviceAddressesForPeers(): String fun getDebugStatus(): String diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 9434fc6e..ba106282 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -9,6 +9,7 @@ import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.sync.PacketIdUtil +import com.bitchat.android.nostr.MeshMessageIdentity import com.bitchat.android.util.toHexString import com.bitchat.android.features.voice.LiveVoiceManager import com.bitchat.android.features.voice.LiveVoiceScope @@ -118,7 +119,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Notify delegate delegate?.onMessageReceived(message) - // Send delivery ACK exactly like iOS + // An ACK means durable admission, including a previously deleted duplicate. + if (!com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)) return false sendDeliveryAck(privateMessage.messageID, peerID) } } @@ -128,7 +130,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data) if (file != null) { Log.d(TAG, "Encrypted file from $peerID: ${file.fileSize} bytes") - val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() + val stableID = com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(peerID, myPeerID, file.fileName) + if (stableID != null) { + when (com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID)) { + com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED, + com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED -> { + sendDeliveryAck(stableID, peerID) + return true + } + com.bitchat.android.services.PrivateMediaReceiptState.UNAVAILABLE -> return false + com.bitchat.android.services.PrivateMediaReceiptState.ABSENT -> Unit + } + } + val uniqueMsgId = stableID ?: java.util.UUID.randomUUID().toString().uppercase() val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( id = uniqueMsgId, @@ -146,8 +160,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro delegate?.onMessageReceived(message) } - // Send delivery ACK with generated message ID - sendDeliveryAck(uniqueMsgId, peerID) + if (stableID == null) { + sendDeliveryAck(uniqueMsgId, peerID) + } else { + val receipt = com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID) + if (receipt == com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED || + receipt == com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED) { + sendDeliveryAck(stableID, peerID) + } else { + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(appContext, listOf(savedPath)) + return false + } + } } else { Log.w(TAG, "Failed to decode encrypted file transfer from $peerID") } @@ -199,6 +223,23 @@ 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 + ) + } + com.bitchat.android.model.NoisePayloadType.VOUCH -> { + delegate?.onVouchPayloadReceived(peerID, noisePayload.data) + } } } catch (e: Exception) { @@ -207,6 +248,32 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro return true } + /** Admit an already authenticated Noise X payload through the normal private-message path. */ + suspend fun handleOpenedCourierPayload(routed: RoutedPacket): Boolean { + val packet = routed.packet + val peerID = routed.peerID ?: return false + val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return false + if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.DELIVERED) { + val messageID = noisePayload.data.toString(Charsets.UTF_8) + if (messageID.isBlank()) return false + delegate?.onDeliveryAckReceived(messageID, peerID) + return true + } + if (noisePayload.type != com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) return false + val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return false + val message = BitchatMessage( + id = privateMessage.messageID, + sender = delegate?.getPeerNickname(peerID) ?: "Unknown", + content = privateMessage.content, + timestamp = Date(packet.timestamp.toLong()), + isPrivate = true, + recipientNickname = delegate?.getMyNickname(), + senderPeerID = peerID + ) + delegate?.onMessageReceived(message) + return com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message) + } + /** * Count consecutive decrypt failures from a signature-verified peer that we still hold an * established session for. After repeated failures the session is stale (the peer completed @@ -230,6 +297,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 @@ -348,6 +422,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro capabilities = announcement.capabilities ) ?: false + BridgeMeshPort.handleVerifiedAnnouncement( + peerID, + announcement + ) + // Update mesh graph from gossip neighbors (only if TLV present) try { val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) @@ -453,6 +532,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro private suspend fun handleBroadcastMessage(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" + if (packet.timestamp > Long.MAX_VALUE.toULong()) return + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + if (ageMs !in + -com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS.. + com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS + ) return // Enforce: only accept public messages from verified peers we know val peerInfo = delegate?.getPeerInfo(peerID) @@ -486,13 +571,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Fallback: plain text val message = BitchatMessage( - id = PacketIdUtil.computeIdHex(packet).uppercase(), + id = MeshMessageIdentity.stableId( + peerID, + packet.timestamp.toLong(), + String(packet.payload, Charsets.UTF_8) + ), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) ) delegate?.onMessageReceived(message) + BridgeMeshPort.handleAuthenticatedRadioMessage(message.id) } catch (e: Exception) { Log.e(TAG, "Failed to process broadcast message: ${e.message}") } @@ -738,4 +828,16 @@ 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) {} + fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {} } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 37a295e8..8d175a06 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -122,6 +122,22 @@ class PacketProcessor(private val myPeerID: String) { var validPacket = true val messageType = MessageType.fromValue(packet.type) + val isBroadcast = packet.recipientID == null || + packet.recipientID.contentEquals(com.bitchat.android.protocol.SpecialRecipients.BROADCAST) + if (isBroadcast && packet.timestamp <= Long.MAX_VALUE.toULong()) { + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + val maxAgeMs = when (messageType) { + MessageType.MESSAGE -> com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS + MessageType.FRAGMENT, MessageType.FILE_TRANSFER -> + com.bitchat.android.sync.GossipSyncManager.FRAGMENT_MAX_AGE_MS + else -> null + } + if (maxAgeMs != null && ageMs !in + -com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..maxAgeMs + ) return + } else if (isBroadcast && packet.timestamp > Long.MAX_VALUE.toULong()) { + return + } // Verbose logging to debug manager (and chat via ChatViewModel observer) try { val mt = messageType?.name ?: packet.type.toString() @@ -137,15 +153,35 @@ 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.BOARD_POST -> validPacket = handleBoardPost(routed) MessageType.LEAVE -> handleLeave(routed) MessageType.FRAGMENT -> handleFragment(routed) MessageType.REQUEST_SYNC -> handleRequestSync(routed) + MessageType.PREKEY_BUNDLE -> { + BridgeMeshPort.handlePrekeyPacket(packet) + } + MessageType.NOSTR_CARRIER -> { + val directedToUs = packetRelayManager.isPacketAddressedToMe(packet) + val isBroadcast = packet.recipientID == null || + packet.recipientID.contentEquals(delegate?.getBroadcastRecipient()) + if (directedToUs || isBroadcast) { + BridgeMeshPort.handleCarrier( + packet.payload, + peerID, + directedToUs + ) + } + } else -> { // Handle private packet types (address check required) if (packetRelayManager.isPacketAddressedToMe(packet)) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) + MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false + MessageType.PING -> delegate?.handlePing(routed) + MessageType.PONG -> delegate?.handlePong(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false @@ -195,6 +231,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 @@ -226,8 +268,24 @@ class PacketProcessor(private val myPeerID: String) { * Handle REQUEST_SYNC packets (public, TTL=1) */ private suspend fun handleRequestSync(routed: RoutedPacket) { + val peerID = routed.peerID ?: "unknown" + if (routed.packet.ttl != com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) { + Log.w(TAG, "Dropping non-link-local REQUEST_SYNC from ${formatPeerForLog(peerID)}") + return + } + Log.d(TAG, "Processing REQUEST_SYNC from ${formatPeerForLog(peerID)}") delegate?.handleRequestSync(routed) } + + /** + * Board packets are self-authenticating. The delegate verifies their + * embedded Ed25519 signature and returns false for anything that must not relay. + */ + private fun handleBoardPost(routed: RoutedPacket): Boolean { + val peerID = routed.peerID ?: "unknown" + Log.d(TAG, "Processing board packet from ${formatPeerForLog(peerID)}") + return delegate?.handleBoardPost(routed) ?: false + } /** * Handle delivery acknowledgment @@ -291,16 +349,22 @@ interface PacketProcessorDelegate { // Network information fun getNetworkSize(): Int fun getBroadcastRecipient(): ByteArray + fun isPeerDirectlyConnected(peerID: String): Boolean = false // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean fun handleNoiseEncrypted(routed: RoutedPacket): Boolean + fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false 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) + fun handleBoardPost(routed: RoutedPacket): Boolean = false + fun handlePing(routed: RoutedPacket) {} + fun handlePong(routed: RoutedPacket) {} // Communication fun sendAnnouncementToPeer(peerID: String) diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index 6b5c6d16..4d87366b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -4,8 +4,10 @@ import com.bitchat.android.protocol.MessageType import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MeshDiagnosticsConstants import com.bitchat.android.util.toHexString import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap import kotlin.random.Random /** @@ -32,6 +34,7 @@ class PacketRelayManager(private val myPeerID: String) { // Coroutines private val relayScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val diagnosticRelayTimestamps = ConcurrentHashMap>() /** * Main entry point for relay decisions @@ -60,6 +63,13 @@ class PacketRelayManager(private val myPeerID: String) { Log.d(TAG, "TTL expired, not relaying packet") return } + + val isDiagnostic = MessageType.fromValue(packet.type) in + setOf(MessageType.PING, MessageType.PONG) + if (isDiagnostic && !consumeDiagnosticRelayBudget(routed, peerID)) { + Log.w(TAG, "Diagnostic relay budget exhausted for ingress link") + return + } // Decrement TTL by 1 val networkSize = delegate?.getNetworkSize() ?: 1 @@ -108,11 +118,36 @@ class PacketRelayManager(private val myPeerID: String) { // Apply relay logic based on packet type and debug switch val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID) if (shouldRelay) { + if (isDiagnostic) { + delay( + Random.nextLong( + MeshDiagnosticsConstants.RELAY_JITTER_MIN_MILLIS, + MeshDiagnosticsConstants.RELAY_JITTER_MAX_MILLIS + 1, + ) + ) + } relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress)) } else { Log.d(TAG, "Relay decision: NOT relaying packet type ${packet.type}") } } + + private fun consumeDiagnosticRelayBudget(routed: RoutedPacket, fallbackPeerID: String): Boolean { + val ingressKey = routed.ingressLinkID ?: routed.relayAddress ?: fallbackPeerID + val now = System.currentTimeMillis() + val timestamps = diagnosticRelayTimestamps.computeIfAbsent(ingressKey) { ArrayDeque() } + synchronized(timestamps) { + while (timestamps.firstOrNull()?.let { + now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS + } == true + ) { + timestamps.removeFirst() + } + if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false + timestamps.addLast(now) + return true + } + } /** * Check if a packet is specifically addressed to us @@ -140,6 +175,9 @@ class PacketRelayManager(private val myPeerID: String) { * Determine if we should relay this packet based on type and network conditions */ private fun shouldRelayPacket(packet: BitchatPacket, fromPeerID: String): Boolean { + if (MessageType.fromValue(packet.type) in setOf(MessageType.PING, MessageType.PONG)) { + return true + } // Always relay if TTL is high enough (indicates important message) if (packet.ttl >= 4u) { Log.d(TAG, "High TTL (${packet.ttl}), relaying") diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 6fab9f03..f3565005 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -89,8 +89,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private } } - // Enforce mandatory signature verification - if (!verifyPacketSignature(packet, peerID)) { + // Mesh diagnostics are intentionally unsigned for iOS wire compatibility. + val isUnsignedDiagnostic = + messageType in setOf(MessageType.PING, MessageType.PONG) && packet.signature == null + if (!isUnsignedDiagnostic && !verifyPacketSignature(packet, peerID)) { return false } @@ -257,8 +259,11 @@ class SecurityManager(private val encryptionService: EncryptionService, private MessageType.ANNOUNCE, MessageType.MESSAGE, MessageType.FILE_TRANSFER, + MessageType.COURIER_ENVELOPE, MessageType.VOICE_FRAME, - MessageType.LEAVE + MessageType.LEAVE, + MessageType.REQUEST_SYNC, + MessageType.NOSTR_CARRIER )) { return true } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 3efe758d..eb7232c1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -5,7 +5,9 @@ import android.util.Log import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.RoutedPacket import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,6 +39,10 @@ class UnifiedMeshService( private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val powerManager = PowerManager.getInstance(context.applicationContext) private var announcementJob: Job? = null + private val diagnosticsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val meshPingManager = MeshPingManager(bluetooth.myPeerID, diagnosticsScope) { packet -> + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } override val myPeerID: String get() = bluetooth.myPeerID @@ -101,6 +107,29 @@ class UnifiedMeshService( } } + override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) { + when { + isBleEnabled() -> bluetooth.sendNostrCarrier(payload, recipientPeerID) + else -> wifiService()?.sendNostrCarrier(payload, recipientPeerID) + } + } + + override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) { + when { + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendCourierEnvelope(payload, recipientPeerID) + else -> wifiService()?.sendCourierEnvelope(payload, recipientPeerID) + } + } + + override fun sendPrekeyBundle(payload: ByteArray) { + if (isBleEnabled()) { + bluetooth.sendPrekeyBundle(payload) + } else { + wifiService()?.sendPrekeyBundle(payload) + } + } + override fun sendPrivateMessage( content: String, recipientPeerID: String, @@ -116,6 +145,25 @@ class UnifiedMeshService( } } + override fun getPeerInfos(): List = bluetooth.getPeerInfos() + + override fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List = bluetooth.sendCourierMessage(content, messageID, recipientNoiseKey, courierPeerIDs) + + override fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit + ): Boolean = bluetooth.sendBridgeCourierMessage(content, messageID, recipientNoiseKey, onAccepted) + + override fun supportsPrivateMediaReceipts(peerID: String): Boolean = + bluetooth.supportsPrivateMediaReceipts(peerID) || wifiService()?.supportsPrivateMediaReceipts(peerID) == true + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { when { isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) @@ -150,6 +198,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) @@ -184,6 +256,15 @@ class UnifiedMeshService( } } + override fun sendBoardPayload(payload: ByteArray) { + when { + isBleEnabled() -> bluetooth.sendBoardPayload(payload) + else -> wifiService()?.sendBoardPayload(payload) + } + } + + + override fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, @@ -239,6 +320,10 @@ class UnifiedMeshService( } } + override fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) { + meshPingManager.ping(peerID, callback) + } + override fun getPeerNicknames(): Map { val merged = linkedMapOf() try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { } @@ -327,6 +412,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) } @@ -351,6 +442,13 @@ class UnifiedMeshService( return merged } + override fun getDirectBlePeerIDs(): Set = + try { + bluetooth.getDeviceAddressToPeerMapping().values.toSet() + } catch (_: Exception) { + emptySet() + } + override fun printDeviceAddressesForPeers(): String { return buildString { appendLine(bluetooth.printDeviceAddressesForPeers()) @@ -376,8 +474,10 @@ class UnifiedMeshService( } override fun clearAllInternalData() { - try { bluetooth.clearAllInternalData() } catch (_: Exception) { } - try { wifiService()?.clearAllInternalData() } catch (_: Exception) { } + val bluetoothResult = runCatching { bluetooth.clearAllInternalData() } + val wifiResult = runCatching { wifiService()?.clearAllInternalData() } + bluetoothResult.getOrThrow() + wifiResult.getOrThrow() } override fun clearAllEncryptionData() { @@ -413,6 +513,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) } diff --git a/app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt b/app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt new file mode 100644 index 00000000..8e5d6fb0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt @@ -0,0 +1,127 @@ +package com.bitchat.android.mesh + +import android.util.Log +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +/** + * Transport-neutral exchange and acceptance policy for transitive verification. + * + * All payloads supplied to [handlePayload] have already been authenticated and + * decrypted by the Noise session for [peerID]. + */ +class VouchCoordinator( + private val scope: CoroutineScope, + private val identity: SecureIdentityStateManager, + private val connectedPeerIDs: () -> Collection, + private val fingerprintForPeer: (String) -> String?, + private val peerInfo: (String) -> PeerInfo?, + private val signingKeyForFingerprint: (String) -> ByteArray?, + private val hasEstablishedSession: (String) -> Boolean, + private val sign: (ByteArray) -> ByteArray?, + private val verify: (ByteArray, ByteArray, ByteArray) -> Boolean, + private val send: (String, ByteArray) -> Boolean +) { + init { + scope.launch { + SecureIdentityStateManager.changes.collect { + vouchToConnectedVerifiedPeers() + } + } + } + + fun peerAuthenticated(peerID: String, fingerprint: String) { + attemptVouch(peerID, fingerprint) + } + + fun peersUpdated(peerIDs: Collection) { + peerIDs.forEach { peerID -> + fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it) } + } + } + + fun vouchToConnectedVerifiedPeers(nowMs: Long = System.currentTimeMillis()) { + connectedPeerIDs().forEach { peerID -> + fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it, nowMs) } + } + } + + fun attemptVouch( + peerID: String, + peerFingerprint: String, + nowMs: Long = System.currentTimeMillis() + ): Boolean { + val normalizedPeerFingerprint = peerFingerprint.lowercase() + if (!hasEstablishedSession(peerID) || + !identity.isVerifiedFingerprint(normalizedPeerFingerprint) + ) return false + + val capabilities = peerInfo(peerID)?.capabilities + if (capabilities != null && capabilities != PeerCapabilities.NONE && + !capabilities.contains(PeerCapabilities.VOUCH) + ) return false + + val lastSent = identity.lastVouchBatchSent(normalizedPeerFingerprint) + if (lastSent != null && nowMs - lastSent < BATCH_INTERVAL_MS) return false + + val attestations = identity.mostRecentlyVerifiedFingerprints( + VouchAttestation.MAX_BATCH_COUNT, + excluding = normalizedPeerFingerprint + ).mapNotNull { vouchee -> + val fingerprintBytes = vouchee.dataFromHexString() ?: return@mapNotNull null + val signingKey = signingKeyForFingerprint(vouchee) ?: return@mapNotNull null + VouchAttestation.build(fingerprintBytes, signingKey, nowMs, sign) + } + val payload = VouchAttestation.encodeList(attestations) ?: return false + if (!send(peerID, payload)) return false + identity.markVouchBatchSent(normalizedPeerFingerprint, nowMs) + Log.d(TAG, "Sent ${attestations.size} vouch(es) to ${peerID.take(LOG_FINGERPRINT_LENGTH)}") + return true + } + + fun handlePayload( + peerID: String, + payload: ByteArray, + nowMs: Long = System.currentTimeMillis() + ) { + val senderFingerprint = fingerprintForPeer(peerID)?.lowercase() ?: return + if (!identity.isVerifiedFingerprint(senderFingerprint)) return + val senderSigningKey = signingKeyForFingerprint(senderFingerprint) ?: return + + var accepted = INITIAL_ACCEPTED_COUNT + VouchAttestation.decodeList(payload).forEach { attestation -> + if (!attestation.isExpired(nowMs) && + verify(attestation.signature, attestation.signableBytes(), senderSigningKey) && + identity.recordVouch( + attestation.voucheeFingerprint.hexEncodedString(), + senderFingerprint, + attestation.voucheeSigningKey, + attestation.timestampMs, + nowMs + ) + ) accepted++ + } + if (accepted > INITIAL_ACCEPTED_COUNT) { + Log.i(TAG, "Accepted $accepted vouch(es) from ${peerID.take(LOG_FINGERPRINT_LENGTH)}") + } + } + + companion object { + private const val TAG = "VouchCoordinator" + private const val INITIAL_ACCEPTED_COUNT = 0 + private const val LOG_FINGERPRINT_LENGTH = 8 + private const val HOURS_PER_DAY = 24L + private const val MINUTES_PER_HOUR = 60L + private const val SECONDS_PER_MINUTE = 60L + private const val MILLIS_PER_SECOND = 1000L + const val BATCH_INTERVAL_MS = + HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + val NOISE_PACKET_VERSION: UByte = 1u + } +} diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 528a269e..c0ebf74b 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -22,6 +22,9 @@ sealed class DeliveryStatus : Parcelable { @Parcelize object Sending : DeliveryStatus() + @Parcelize + object Queued : DeliveryStatus() + @Parcelize object Sent : DeliveryStatus() @@ -40,6 +43,7 @@ sealed class DeliveryStatus : Parcelable { fun getDisplayText(): String { return when (this) { is Sending -> "Sending..." + is Queued -> "Queued" is Sent -> "Sent" is Delivered -> "Delivered to ${this.to}" is Read -> "Read by ${this.by}" @@ -77,7 +81,15 @@ data class BitchatMessage( * surfaces color the sender by the same stable key while [senderPeerID] remains available for * mesh IDs and private-chat routing aliases. */ - val senderNostrPubkey: String? = null + val senderNostrPubkey: String? = null, + /** Rendered from a signed bridge rendezvous event rather than local radio. */ + val isBridged: Boolean = false, + /** + * Untrusted radio-coordinate hint from the bridge event. It may merge a + * duplicate when the authenticated radio copy arrives, but never owns the + * bridge row's primary ID. + */ + val bridgeRadioMessageIdHint: String? = null ) : Parcelable { /** @@ -362,4 +374,3 @@ data class BitchatMessage( return result } } - diff --git a/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt new file mode 100644 index 00000000..4765a273 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt @@ -0,0 +1,115 @@ +package com.bitchat.android.model + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** Opaque store-and-forward envelope, wire-compatible with iOS CourierEnvelope. */ +data class CourierEnvelope( + val recipientTag: ByteArray, + val expiry: ULong, + val ciphertext: ByteArray, + val copies: UByte = 1u, + val prekeyID: UInt? = null +) { + companion object { + const val TAG_LENGTH = 16 + const val MAX_CIPHERTEXT_BYTES = 16 * 1024 + const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L + const val MAX_COPIES = 8 + private val TAG_DOMAIN = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8) + + fun epochDay(nowMs: Long): UInt = Math.floorDiv(nowMs, 86_400_000L).toUInt() + + fun recipientTag(noiseStaticKey: ByteArray, epochDay: UInt): ByteArray { + require(noiseStaticKey.size == 32) + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256")) + mac.update(TAG_DOMAIN) + mac.update(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epochDay.toInt()).array()) + return mac.doFinal().copyOf(TAG_LENGTH) + } + + fun decode(data: ByteArray): CourierEnvelope? { + var offset = 0 + var tag: ByteArray? = null + var expiry: ULong? = null + var ciphertext: ByteArray? = null + var copies: UByte = 1u + var prekeyID: UInt? = null + while (offset + 3 <= data.size) { + val type = data[offset].toUByte() + 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 + val value = data.copyOfRange(offset, offset + length) + offset += length + when (type.toInt()) { + 1 -> if (length == TAG_LENGTH && tag == null) tag = value else return null + 2 -> if (length == 8 && expiry == null) expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long.toULong() else return null + 3 -> if (ciphertext == null) ciphertext = value else return null + 4 -> { + if (length != 1 || copies != 1u.toUByte()) return null + copies = value[0].toUByte() + if (copies !in 2u.toUByte()..MAX_COPIES.toUByte()) return null + } + 5 -> if (length == 4 && prekeyID == null) { + prekeyID = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toUInt() + } else { + return null + } + } + } + if (offset != data.size) return null + val requiredTag = tag ?: return null + val requiredExpiry = expiry ?: return null + val requiredCiphertext = ciphertext ?: return null + if (requiredCiphertext.isEmpty() || requiredCiphertext.size > MAX_CIPHERTEXT_BYTES) return null + return CourierEnvelope(requiredTag, requiredExpiry, requiredCiphertext, copies, prekeyID) + } + } + + fun encode(): ByteArray? { + if (recipientTag.size != TAG_LENGTH || ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES || + copies !in 1u.toUByte()..MAX_COPIES.toUByte() + ) return null + val fields = mutableListOf>() + fields += 1 to recipientTag + fields += 2 to ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(expiry.toLong()).array() + fields += 3 to ciphertext + if (copies > 1u) fields += 4 to byteArrayOf(copies.toByte()) + prekeyID?.let { + fields += 5 to ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array() + } + val size = fields.sumOf { 3 + it.second.size } + val buffer = ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN) + fields.forEach { (type, value) -> + buffer.put(type.toByte()) + buffer.putShort(value.size.toShort()) + buffer.put(value) + } + return buffer.array() + } + + fun matchesRecipient(noiseStaticKey: ByteArray, nowMs: Long): Boolean { + val day = epochDay(nowMs) + return listOf(day - 1u, day, day + 1u).any { + recipientTag.contentEquals(recipientTag(noiseStaticKey, it)) + } + } + + override fun equals(other: Any?): Boolean = other is CourierEnvelope && + recipientTag.contentEquals(other.recipientTag) && expiry == other.expiry && + ciphertext.contentEquals(other.ciphertext) && copies == other.copies && prekeyID == other.prekeyID + + override fun hashCode(): Int { + var result = recipientTag.contentHashCode() + result = 31 * result + expiry.hashCode() + result = 31 * result + ciphertext.contentHashCode() + result = 31 * result + copies.hashCode() + result = 31 * result + (prekeyID?.hashCode() ?: 0) + return result + } +} diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index c48bd6cc..21ccbc5e 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -13,7 +13,8 @@ data class IdentityAnnouncement( val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) val signingPublicKey: ByteArray, // Ed25519 public key for signing val capabilities: PeerCapabilities? = null, - val unknownTLVs: List = emptyList() + val unknownTLVs: List = emptyList(), + val bridgeGeohash: String? = null ) : Parcelable { /** @@ -23,7 +24,8 @@ data class IdentityAnnouncement( NICKNAME(0x01u), NOISE_PUBLIC_KEY(0x02u), SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key - CAPABILITIES(0x05u); + CAPABILITIES(0x05u), + BRIDGE_GEOHASH(0x06u); companion object { fun fromValue(value: UByte): TLVType? { @@ -39,6 +41,9 @@ data class IdentityAnnouncement( val nicknameData = nickname.toByteArray(Charsets.UTF_8) // Check size limits + val bridgeGeohashData = bridgeGeohash + ?.toByteArray(Charsets.UTF_8) + ?.takeIf { it.size in 1..12 } if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 || unknownTLVs.any { it.value.size > 255 }) { return null @@ -68,6 +73,12 @@ data class IdentityAnnouncement( result.addAll(capabilityBytes.toList()) } + bridgeGeohashData?.let { geohash -> + result.add(TLVType.BRIDGE_GEOHASH.value.toByte()) + result.add(geohash.size.toByte()) + result.addAll(geohash.toList()) + } + // Preserve extensions this build does not understand. This includes // gossip TLV 0x04 when an announcement is decoded through this model. unknownTLVs.forEach { tlv -> @@ -92,6 +103,7 @@ data class IdentityAnnouncement( var noisePublicKey: ByteArray? = null var signingPublicKey: ByteArray? = null var capabilities: PeerCapabilities? = null + var bridgeGeohash: String? = null val unknownTLVs = mutableListOf() while (offset + 2 <= dataCopy.size) { @@ -125,6 +137,10 @@ data class IdentityAnnouncement( TLVType.CAPABILITIES -> { capabilities = PeerCapabilities.decode(value) } + TLVType.BRIDGE_GEOHASH -> { + if (value.size !in 1..12) return null + bridgeGeohash = String(value, Charsets.UTF_8) + } null -> { // Retain unknown extensions so callers can forward or // re-encode the announcement without erasing them. @@ -135,7 +151,14 @@ data class IdentityAnnouncement( // All three fields are required return if (nickname != null && noisePublicKey != null && signingPublicKey != null) { - IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs) + IdentityAnnouncement( + nickname, + noisePublicKey, + signingPublicKey, + capabilities, + unknownTLVs, + bridgeGeohash + ) } else { null } @@ -145,12 +168,14 @@ data class IdentityAnnouncement( fun forLocalPeer( nickname: String, noisePublicKey: ByteArray, - signingPublicKey: ByteArray + signingPublicKey: ByteArray, + bridgeGeohash: String? = null ): IdentityAnnouncement = IdentityAnnouncement( nickname = nickname, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, - capabilities = PeerCapabilities.LOCAL_SUPPORTED + capabilities = PeerCapabilities.localSupported(), + bridgeGeohash = bridgeGeohash ) } @@ -166,6 +191,7 @@ data class IdentityAnnouncement( if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false if (capabilities != other.capabilities) return false if (unknownTLVs != other.unknownTLVs) return false + if (bridgeGeohash != other.bridgeGeohash) return false return true } @@ -176,10 +202,11 @@ data class IdentityAnnouncement( result = 31 * result + signingPublicKey.contentHashCode() result = 31 * result + (capabilities?.hashCode() ?: 0) result = 31 * result + unknownTLVs.hashCode() + result = 31 * result + (bridgeGeohash?.hashCode() ?: 0) return result } override fun toString(): String { - return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})" + return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue}, bridgeGeohash=$bridgeGeohash)" } } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index aacdafd4..7aa0e843 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -22,8 +22,11 @@ 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 + VOUCH(0x12u), // Transitive verification attestations FILE_TRANSFER(0x20u), /** Authenticated capabilities + Ed25519 binding for the current Noise generation. */ PEER_STATE(0x21u); diff --git a/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt new file mode 100644 index 00000000..3f6be5a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt @@ -0,0 +1,101 @@ +package com.bitchat.android.model + +import com.bitchat.android.nostr.NostrEvent + +/** + * Wire payload for MessageType.NOSTR_CARRIER (0x28). + * + * The TLV layout and limits intentionally match iOS. Lengths are unsigned + * 16-bit big-endian values and unknown TLVs are skipped. + */ +data class NostrCarrierPacket( + val direction: Direction, + val geohash: String, + val eventJson: ByteArray +) { + enum class Direction(val value: Int) { + TO_GATEWAY(0x01), + FROM_GATEWAY(0x02), + TO_BRIDGE(0x03), + FROM_BRIDGE(0x04); + + companion object { + fun fromValue(value: Int): Direction? = entries.firstOrNull { it.value == value } + } + } + + init { + require(geohash.toByteArray(Charsets.UTF_8).size in 1..MAX_GEOHASH_LENGTH) + require(eventJson.size in 1..MAX_EVENT_JSON_BYTES) + } + + fun event(): NostrEvent? = + NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8)) + + fun encode(): ByteArray { + return checkNotNull( + Tlv16Codec.encode( + Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())), + Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)), + Tlv16Codec.Field(TLV_EVENT_JSON, eventJson) + ) + ) + } + + companion object { + const val MAX_EVENT_JSON_BYTES = 16 * 1024 + const val MAX_GEOHASH_LENGTH = 12 + + private const val TLV_DIRECTION = 0x01 + private const val TLV_GEOHASH = 0x02 + private const val TLV_EVENT_JSON = 0x03 + + fun fromEvent(direction: Direction, geohash: String, event: NostrEvent): NostrCarrierPacket? = + runCatching { + NostrCarrierPacket( + direction = direction, + geohash = geohash, + eventJson = event.toJsonString().toByteArray(Charsets.UTF_8) + ) + }.getOrNull() + + fun decode(data: ByteArray): NostrCarrierPacket? { + var direction: Direction? = null + var geohash: String? = null + var eventJson: ByteArray? = null + + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { + TLV_DIRECTION -> { + if (field.value.size != 1) return null + direction = + Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null + } + TLV_GEOHASH -> { + geohash = field.value.toString(Charsets.UTF_8) + } + TLV_EVENT_JSON -> eventJson = field.value + } + } ?: return null + + return runCatching { + NostrCarrierPacket( + direction = direction ?: return null, + geohash = geohash ?: return null, + eventJson = eventJson ?: return null + ) + }.getOrNull() + } + + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is NostrCarrierPacket && + direction == other.direction && + geohash == other.geohash && + eventJson.contentEquals(other.eventJson)) + + override fun hashCode(): Int = + 31 * (31 * direction.hashCode() + geohash.hashCode()) + eventJson.contentHashCode() +} diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index e64fc390..bf5fb4fe 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -1,6 +1,7 @@ package com.bitchat.android.model import android.os.Parcelable +import com.bitchat.android.protocol.MeshDiagnosticsConstants import kotlinx.parcelize.Parcelize /** @@ -27,27 +28,57 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { } companion object { + private const val VOUCH_BIT_INDEX = 5 + private const val PRIVATE_MEDIA_BIT_INDEX = 8 val NONE = PeerCapabilities(0) - val PREKEYS = PeerCapabilities(1L shl 0) val WIFI_BULK = PeerCapabilities(1L shl 1) val GATEWAY = PeerCapabilities(1L shl 2) val GROUPS = PeerCapabilities(1L shl 3) val BOARD = PeerCapabilities(1L shl 4) - val VOUCH = PeerCapabilities(1L shl 5) val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6) - val BRIDGE = PeerCapabilities(1L shl 7) /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ - val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + val PRIVATE_MEDIA = PeerCapabilities(1L shl PRIVATE_MEDIA_BIT_INDEX) + + /** Transitive verification attestations over authenticated Noise. */ + val VOUCH = PeerCapabilities(1L shl VOUCH_BIT_INDEX) val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9) /** Reserved by iOS; decode it but do not advertise or act on it. */ val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10) + /** Can bridge public mesh traffic through geohash rendezvous relays. */ + val BRIDGE = PeerCapabilities(1L shl 7) + + /** Publishes signed one-time prekeys for forward-secret courier mail. */ + val PREKEYS = PeerCapabilities(1L shl 0) + + /** Capabilities implemented by this Android build. */ - val LOCAL_SUPPORTED = PRIVATE_MEDIA + @Deprecated("Use localSupported() so runtime bridge state is included") + val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue) + + @Volatile + private var bridgeEnabled: Boolean = false + @Volatile private var gatewayEnabled: Boolean = false + fun setGatewayEnabled(enabled: Boolean) { gatewayEnabled = enabled } + + @Volatile private var phoneFeaturesEnabled = false + + fun setPhoneFeaturesEnabled(enabled: Boolean) { phoneFeaturesEnabled = enabled } + + fun setBridgeEnabled(enabled: Boolean) { + bridgeEnabled = enabled + } + + fun localSupported(): PeerCapabilities = PeerCapabilities( + LOCAL_SUPPORTED.rawValue or + (if (phoneFeaturesEnabled) PREKEYS.rawValue or GROUPS.rawValue or PRIVATE_MEDIA_RECEIPTS.rawValue else 0L) or + (if (bridgeEnabled) BRIDGE.rawValue else 0L) or + (if (gatewayEnabled) GATEWAY.rawValue else 0L) + ) /** * Decode the low 64 bits and ignore any future extension bytes, which diff --git a/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt new file mode 100644 index 00000000..be5d7a81 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt @@ -0,0 +1,171 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Signed batch of one-time Curve25519 keys carried by MessageType.PREKEY_BUNDLE (0x24). + * + * The canonical signing bytes and TLV representation intentionally match the + * iOS BitFoundation implementation byte-for-byte. + */ +data class PrekeyBundle( + val noiseStaticPublicKey: ByteArray, + val prekeys: List, + val generatedAt: Long, + val signature: ByteArray +) { + data class Prekey(val id: Long, val publicKey: ByteArray) { + init { + require(id in 0..0xFFFF_FFFFL) + require(publicKey.size == KEY_LENGTH) + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey)) + + override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode() + } + + fun signableBytes(): ByteArray { + val output = ByteArrayOutputStream( + 1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES + ) + output.write(SIGNING_CONTEXT.size) + output.write(SIGNING_CONTEXT) + output.write(fixedKey(noiseStaticPublicKey)) + output.write(prekeys.size.coerceAtMost(0xFF)) + prekeys.take(0xFF).forEach { prekey -> + output.write(uint32Bytes(prekey.id)) + output.write(fixedKey(prekey.publicKey)) + } + output.write(uint64Bytes(generatedAt)) + return output.toByteArray() + } + + fun encode(): ByteArray? { + if (noiseStaticPublicKey.size != KEY_LENGTH || + signature.size != SIGNATURE_LENGTH || + prekeys.isEmpty() || + prekeys.size > MAX_PREKEYS || + prekeys.map { it.id }.distinct().size != prekeys.size + ) { + return null + } + + val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH) + prekeys.forEach { prekey -> + if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null + entries.write(uint32Bytes(prekey.id)) + entries.write(prekey.publicKey) + } + + return Tlv16Codec.encode( + Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey), + Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()), + Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)), + Tlv16Codec.Field(TLV_SIGNATURE, signature) + ) + } + + companion object { + const val KEY_LENGTH = 32 + const val SIGNATURE_LENGTH = 64 + const val MAX_PREKEYS = 8 + private const val PREKEY_ENTRY_LENGTH = 4 + KEY_LENGTH + + private val SIGNING_CONTEXT = "bitchat-prekey-bundle-v1".toByteArray(Charsets.UTF_8) + private const val TLV_NOISE_STATIC_KEY = 0x01 + private const val TLV_PREKEYS = 0x02 + private const val TLV_GENERATED_AT = 0x03 + private const val TLV_SIGNATURE = 0x04 + + fun decode(data: ByteArray): PrekeyBundle? { + var noiseStaticKey: ByteArray? = null + var prekeys: List? = null + var generatedAt: Long? = null + var signature: ByteArray? = null + + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { + TLV_NOISE_STATIC_KEY -> { + if (field.value.size != KEY_LENGTH) return null + noiseStaticKey = field.value + } + TLV_PREKEYS -> { + if (field.value.isEmpty() || + field.value.size % PREKEY_ENTRY_LENGTH != 0 || + field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS + ) { + return null + } + val parsed = mutableListOf() + var entryOffset = 0 + while (entryOffset < field.value.size) { + val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .int.toLong() and 0xFFFF_FFFFL + entryOffset += Int.SIZE_BYTES + val publicKey = + field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH) + entryOffset += KEY_LENGTH + parsed += Prekey(id, publicKey) + } + if (parsed.map { it.id }.distinct().size != parsed.size) return null + prekeys = parsed + } + TLV_GENERATED_AT -> { + if (field.value.size != Long.SIZE_BYTES) return null + generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long + } + TLV_SIGNATURE -> { + if (field.value.size != SIGNATURE_LENGTH) return null + signature = field.value + } + } + } ?: return null + + return runCatching { + PrekeyBundle( + noiseStaticPublicKey = noiseStaticKey ?: return null, + prekeys = prekeys?.takeIf { it.isNotEmpty() } ?: return null, + generatedAt = generatedAt ?: return null, + signature = signature ?: return null + ) + }.getOrNull() + } + + private fun uint32Bytes(value: Long): ByteArray = + ByteBuffer.allocate(Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putInt(value.toInt()) + .array() + + private fun uint64Bytes(value: Long): ByteArray = + ByteBuffer.allocate(Long.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putLong(value) + .array() + + private fun fixedKey(key: ByteArray): ByteArray = + key.copyOf(KEY_LENGTH) + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is PrekeyBundle && + noiseStaticPublicKey.contentEquals(other.noiseStaticPublicKey) && + prekeys == other.prekeys && + generatedAt == other.generatedAt && + signature.contentEquals(other.signature)) + + override fun hashCode(): Int { + var result = noiseStaticPublicKey.contentHashCode() + result = 31 * result + prekeys.hashCode() + result = 31 * result + generatedAt.hashCode() + result = 31 * result + signature.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt b/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt new file mode 100644 index 00000000..5b2fdc6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** Stable, direction-bound IDs matching the iOS private-media receipt contract. */ +object PrivateMediaMessageIdentity { + private val stable = Regex("media-[0-9a-f]{32}") + private val uuid = 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}") + private val peer = Regex("[0-9a-f]{16}") + + fun isStableID(value: String): Boolean = stable.matches(value) + + fun stableID(senderPeerID: String, recipientPeerID: String, fileName: String?): String? { + if (fileName.isNullOrEmpty() || '/' in fileName || '\\' in fileName) return null + val sender = senderPeerID.lowercase() + val recipient = recipientPeerID.lowercase() + if (!peer.matches(sender) || !peer.matches(recipient)) return null + val stem = fileName.substringBeforeLast('.', fileName) + val extension = fileName.substringAfterLast('.', "").lowercase() + val isVoice = stem.startsWith("voice_") && extension == "m4a" + val isImage = stem.startsWith("img_") && extension in setOf("jpg", "jpeg") + if (!isVoice && !isImage) return null + val burst = stem.removePrefix("voice_") + if (!uuid.matches(stem.substringAfterLast('_')) && + !(isVoice && Regex("[0-9a-fA-F]{16}").matches(burst))) return null + val input = ByteArrayOutputStream() + input.write("bitchat-private-media-message-v1".toByteArray(Charsets.UTF_8)) + listOf(sender, recipient, fileName).forEach { field -> + val bytes = field.toByteArray(Charsets.UTF_8) + input.write(ByteBuffer.allocate(4).putInt(bytes.size).array()) + input.write(bytes) + } + return "media-" + MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + .take(16).joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt index ab52f071..f7c448ba 100644 --- a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt @@ -1,6 +1,8 @@ package com.bitchat.android.model import com.bitchat.android.sync.SyncDefaults +import com.bitchat.android.sync.GCSFilter +import com.bitchat.android.sync.SyncTypeFlags /** * REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters. @@ -8,11 +10,15 @@ import com.bitchat.android.sync.SyncDefaults * - 0x01: P (uint8) — Golomb-Rice parameter * - 0x02: M (uint32, big-endian) — hash range (N * 2^P) * - 0x03: data (opaque) — GR bitstream bytes + * - 0x04: types (compact little-endian SyncTypeFlags) — packet types covered by the filter + * - 0x05: sinceTimestamp (uint64, big-endian) — oldest timestamp covered by the filter */ data class RequestSyncPacket( val p: Int, val m: Long, - val data: ByteArray + val data: ByteArray, + val types: SyncTypeFlags? = null, + val sinceTimestamp: ULong? = null ) { fun encode(): ByteArray { val out = ArrayList() @@ -38,6 +44,15 @@ data class RequestSyncPacket( ) // data putTLV(0x03, data) + types?.encode()?.let { putTLV(0x04, it) } + sinceTimestamp?.let { timestamp -> + putTLV( + 0x05, + ByteArray(8) { index -> + (timestamp shr ((7 - index) * 8) and 0xffu).toByte() + } + ) + } return out.toByteArray() } @@ -50,6 +65,8 @@ data class RequestSyncPacket( var p: Int? = null var m: Long? = null var payload: ByteArray? = null + var types: SyncTypeFlags? = null + var sinceTimestamp: ULong? = null while (off + 3 <= data.size) { val t = (data[off].toInt() and 0xFF); off += 1 @@ -69,14 +86,20 @@ data class RequestSyncPacket( if (v.size > MAX_ACCEPT_FILTER_BYTES) return null payload = v } + 0x04 -> SyncTypeFlags.decode(v)?.let { types = it } + 0x05 -> if (v.size == 8) { + var timestamp = 0uL + v.forEach { byte -> timestamp = (timestamp shl 8) or byte.toUByte().toULong() } + sinceTimestamp = timestamp + } } } val pp = p ?: return null val mm = m ?: return null val dd = payload ?: return null - if (pp < 1 || mm <= 0L) return null - return RequestSyncPacket(pp, mm, dd) + if (pp !in 1..GCSFilter.MAX_P || mm <= 0L) return null + return RequestSyncPacket(pp, mm, dd, types, sinceTimestamp) } } } diff --git a/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt b/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt new file mode 100644 index 00000000..4cc2783e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt @@ -0,0 +1,47 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream + +/** + * Minimal unsigned 16-bit big-endian TLV codec used by bridge wire models. + * + * Semantic validation intentionally remains in each model. This helper only + * owns framing so every decoder rejects truncated and trailing data the same + * way. + */ +internal object Tlv16Codec { + data class Field(val type: Int, val value: ByteArray) + + fun encode(vararg fields: Field): ByteArray? { + val output = ByteArrayOutputStream( + fields.sumOf { HEADER_SIZE + it.value.size } + ) + fields.forEach { field -> + if (field.type !in 0..0xFF || field.value.size > 0xFFFF) return null + output.write(field.type) + output.write((field.value.size ushr 8) and 0xFF) + output.write(field.value.size and 0xFF) + output.write(field.value) + } + return output.toByteArray() + } + + fun decode(data: ByteArray): List? { + val fields = mutableListOf() + var offset = 0 + while (offset < data.size) { + if (data.size - offset < HEADER_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 += HEADER_SIZE + if (length > data.size - offset) return null + fields += Field(type, data.copyOfRange(offset, offset + length)) + offset += length + } + return fields + } + + private const val HEADER_SIZE = 3 +} diff --git a/app/src/main/java/com/bitchat/android/model/VouchAttestation.kt b/app/src/main/java/com/bitchat/android/model/VouchAttestation.kt new file mode 100644 index 00000000..9987cc15 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/VouchAttestation.kt @@ -0,0 +1,193 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream + +/** + * An iOS-compatible, Ed25519-signed statement that the sender of the enclosing + * authenticated Noise payload verified [voucheeFingerprint]. + */ +data class VouchAttestation( + val voucheeFingerprint: ByteArray, + val voucheeSigningKey: ByteArray, + val timestampMs: Long, + val signature: ByteArray +) { + init { + require(voucheeFingerprint.size == FINGERPRINT_SIZE) + require(voucheeSigningKey.size == SIGNING_KEY_SIZE) + require(signature.size == SIGNATURE_SIZE) + } + + fun signableBytes(): ByteArray = signableBytes( + voucheeFingerprint, + voucheeSigningKey, + timestampMs + ) + + fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean { + val age = nowMs - timestampMs + return age > MAX_AGE_MS || age < -MAX_CLOCK_SKEW_MS + } + + fun encode(): ByteArray { + val output = ByteArrayOutputStream() + output.writeTlv(TYPE_FINGERPRINT, voucheeFingerprint) + output.writeTlv(TYPE_SIGNING_KEY, voucheeSigningKey) + output.writeTlv(TYPE_TIMESTAMP, timestampBytes(timestampMs)) + output.writeTlv(TYPE_SIGNATURE, signature) + return output.toByteArray() + } + + override fun equals(other: Any?): Boolean = + other is VouchAttestation && + voucheeFingerprint.contentEquals(other.voucheeFingerprint) && + voucheeSigningKey.contentEquals(other.voucheeSigningKey) && + timestampMs == other.timestampMs && + signature.contentEquals(other.signature) + + override fun hashCode(): Int { + var result = voucheeFingerprint.contentHashCode() + result = HASH_MULTIPLIER * result + voucheeSigningKey.contentHashCode() + result = HASH_MULTIPLIER * result + timestampMs.hashCode() + return HASH_MULTIPLIER * result + signature.contentHashCode() + } + + companion object { + const val MAX_BATCH_COUNT = 16 + const val FINGERPRINT_SIZE = 32 + const val SIGNING_KEY_SIZE = 32 + const val SIGNATURE_SIZE = 64 + private const val DAYS_VALID = 30L + private const val HOURS_PER_DAY = 24L + private const val MINUTES_PER_HOUR = 60L + private const val SECONDS_PER_MINUTE = 60L + private const val MILLIS_PER_SECOND = 1000L + const val MAX_AGE_MS = + DAYS_VALID * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + const val MAX_CLOCK_SKEW_MS = + MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + + private const val SIGNING_CONTEXT = "bitchat-vouch-v1" + private const val TYPE_FINGERPRINT = 0x01 + private const val TYPE_SIGNING_KEY = 0x02 + private const val TYPE_TIMESTAMP = 0x03 + private const val TYPE_SIGNATURE = 0x04 + private const val TLV_HEADER_SIZE = 2 + private const val TLV_LENGTH_SIZE = 1 + private const val BATCH_COUNT_SIZE = 1 + private const val BATCH_ENTRY_LENGTH_SIZE = 2 + private const val BITS_PER_BYTE = 8 + private const val BYTE_MASK = 0xFF + private const val UINT16_MAX = 0xFFFF + private const val INITIAL_OFFSET = 0 + private const val INITIAL_TIMESTAMP = 0L + private const val MINIMUM_TIMESTAMP_MS = 0L + private const val INITIAL_ENTRY_COUNT = 0 + private const val HASH_MULTIPLIER = 31 + private const val TIMESTAMP_LENGTH = Long.SIZE_BYTES + + fun build( + voucheeFingerprint: ByteArray, + voucheeSigningKey: ByteArray, + timestampMs: Long = System.currentTimeMillis(), + sign: (ByteArray) -> ByteArray? + ): VouchAttestation? { + if (voucheeFingerprint.size != FINGERPRINT_SIZE || + voucheeSigningKey.size != SIGNING_KEY_SIZE + ) return null + val signature = sign(signableBytes(voucheeFingerprint, voucheeSigningKey, timestampMs)) + ?: return null + if (signature.size != SIGNATURE_SIZE) return null + return VouchAttestation(voucheeFingerprint, voucheeSigningKey, timestampMs, signature) + } + + fun signableBytes( + voucheeFingerprint: ByteArray, + voucheeSigningKey: ByteArray, + timestampMs: Long + ): ByteArray = SIGNING_CONTEXT.toByteArray(Charsets.UTF_8) + + voucheeFingerprint + voucheeSigningKey + timestampBytes(timestampMs) + + fun decode(data: ByteArray): VouchAttestation? { + var offset = INITIAL_OFFSET + var fingerprint: ByteArray? = null + var signingKey: ByteArray? = null + var timestamp: Long? = null + var signature: ByteArray? = null + while (offset < data.size) { + if (offset + TLV_HEADER_SIZE > data.size) return null + val type = data[offset++].toInt() and BYTE_MASK + val length = data[offset++].toInt() and BYTE_MASK + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + when (type) { + TYPE_FINGERPRINT -> if (length == FINGERPRINT_SIZE) fingerprint = value else return null + TYPE_SIGNING_KEY -> if (length == SIGNING_KEY_SIZE) signingKey = value else return null + TYPE_TIMESTAMP -> if (length == TIMESTAMP_LENGTH) { + val decodedTimestamp = value.fold(INITIAL_TIMESTAMP) { result, byte -> + (result shl BITS_PER_BYTE) or (byte.toLong() and BYTE_MASK.toLong()) + } + if (decodedTimestamp < MINIMUM_TIMESTAMP_MS) return null + timestamp = decodedTimestamp + } else return null + TYPE_SIGNATURE -> if (length == SIGNATURE_SIZE) signature = value else return null + } + } + return VouchAttestation( + fingerprint ?: return null, + signingKey ?: return null, + timestamp ?: return null, + signature ?: return null + ) + } + + fun encodeList(attestations: List): ByteArray? { + if (attestations.isEmpty() || attestations.size > MAX_BATCH_COUNT) return null + val output = ByteArrayOutputStream() + output.write(attestations.size) + attestations.forEach { attestation -> + val encoded = attestation.encode() + if (encoded.size > UINT16_MAX) return null + output.write(encoded.size ushr BITS_PER_BYTE) + output.write(encoded.size and BYTE_MASK) + output.write(encoded) + } + return output.toByteArray() + } + + fun decodeList(data: ByteArray): List { + if (data.size <= BATCH_COUNT_SIZE) return emptyList() + val limit = minOf(data[0].toInt() and BYTE_MASK, MAX_BATCH_COUNT) + val decoded = mutableListOf() + var offset = BATCH_COUNT_SIZE + var entriesRead = INITIAL_ENTRY_COUNT + while (entriesRead < limit && offset < data.size) { + if (offset + BATCH_ENTRY_LENGTH_SIZE > data.size) break + val length = ((data[offset].toInt() and BYTE_MASK) shl BITS_PER_BYTE) or + (data[offset + TLV_LENGTH_SIZE].toInt() and BYTE_MASK) + offset += BATCH_ENTRY_LENGTH_SIZE + if (offset + length > data.size) break + decode(data.copyOfRange(offset, offset + length))?.let(decoded::add) + offset += length + entriesRead++ + } + return decoded + } + + private const val LAST_BYTE_INDEX_OFFSET = 1 + private const val TIMESTAMP_HIGH_BIT_OFFSET = + (TIMESTAMP_LENGTH - LAST_BYTE_INDEX_OFFSET) * BITS_PER_BYTE + + private fun timestampBytes(timestampMs: Long): ByteArray = + ByteArray(TIMESTAMP_LENGTH) { index -> + (timestampMs ushr (TIMESTAMP_HIGH_BIT_OFFSET - index * BITS_PER_BYTE)).toByte() + } + + private fun ByteArrayOutputStream.writeTlv(type: Int, value: ByteArray) { + write(type) + write(value.size) + write(value) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/CourierNoiseCrypto.kt b/app/src/main/java/com/bitchat/android/noise/CourierNoiseCrypto.kt new file mode 100644 index 00000000..f734cccc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/noise/CourierNoiseCrypto.kt @@ -0,0 +1,121 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.HandshakeState +import com.bitchat.android.noise.southernstorm.protocol.Noise + +/** + * One-message Noise X helper used by iOS-compatible courier envelopes. + * + * Protocol: Noise_X_25519_ChaChaPoly_SHA256 + * Prologue: "bitchat-courier-v1" + */ +object CourierNoiseCrypto { + private const val PROTOCOL_NAME = "Noise_X_25519_ChaChaPoly_SHA256" + private val COURIER_PROLOGUE = "bitchat-courier-v1".toByteArray(Charsets.UTF_8) + private val PREKEY_PROLOGUE_PREFIX = "bitchat-prekey-v1".toByteArray(Charsets.UTF_8) + private const val X_OVERHEAD_BYTES = 32 + 48 + 16 + + data class Opened(val payload: ByteArray, val senderStaticKey: ByteArray) + + fun seal( + payload: ByteArray, + senderStaticPrivateKey: ByteArray, + recipientStaticPublicKey: ByteArray + ): ByteArray = sealWithPrologue( + payload, + senderStaticPrivateKey, + recipientStaticPublicKey, + COURIER_PROLOGUE + ) + + fun sealToPrekey( + payload: ByteArray, + senderStaticPrivateKey: ByteArray, + recipientPrekey: com.bitchat.android.model.PrekeyBundle.Prekey + ): ByteArray = sealWithPrologue( + payload, + senderStaticPrivateKey, + recipientPrekey.publicKey, + prekeyPrologue(recipientPrekey.id) + ) + + private fun sealWithPrologue( + payload: ByteArray, + senderStaticPrivateKey: ByteArray, + recipientStaticPublicKey: ByteArray, + prologue: ByteArray + ): ByteArray { + require(senderStaticPrivateKey.size == 32) + require(recipientStaticPublicKey.size == 32) + val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.INITIATOR) + return try { + handshake.setPrologue(prologue, 0, prologue.size) + handshake.getLocalKeyPair().setPrivateKey(senderStaticPrivateKey, 0) + handshake.getRemotePublicKey().setPublicKey(recipientStaticPublicKey, 0) + handshake.start() + val message = ByteArray(payload.size + X_OVERHEAD_BYTES) + val length = handshake.writeMessage(message, 0, payload, 0, payload.size) + message.copyOf(length) + } finally { + handshake.destroy() + } + } + + fun open( + ciphertext: ByteArray, + recipientStaticPrivateKey: ByteArray + ): Opened = openWithPrologue(ciphertext, recipientStaticPrivateKey, COURIER_PROLOGUE) + + fun openWithPrekey( + ciphertext: ByteArray, + recipientPrekeyPrivateKey: ByteArray, + prekeyId: Long + ): Opened = openWithPrologue( + ciphertext, + recipientPrekeyPrivateKey, + prekeyPrologue(prekeyId) + ) + + private fun openWithPrologue( + ciphertext: ByteArray, + recipientStaticPrivateKey: ByteArray, + prologue: ByteArray + ): Opened { + require(recipientStaticPrivateKey.size == 32) + val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.RESPONDER) + return try { + handshake.setPrologue(prologue, 0, prologue.size) + handshake.getLocalKeyPair().setPrivateKey(recipientStaticPrivateKey, 0) + handshake.start() + val payload = ByteArray(ciphertext.size) + val length = handshake.readMessage(ciphertext, 0, ciphertext.size, payload, 0) + val senderKey = ByteArray(handshake.getRemotePublicKey().publicKeyLength) + handshake.getRemotePublicKey().getPublicKey(senderKey, 0) + Opened(payload.copyOf(length), senderKey) + } finally { + handshake.destroy() + } + } + + /** Test/support helper that derives the X25519 public key used on the wire. */ + fun publicKey(privateKey: ByteArray): ByteArray { + require(privateKey.size == 32) + val key = Noise.createDH("25519") + return try { + key.setPrivateKey(privateKey, 0) + ByteArray(key.publicKeyLength).also { key.getPublicKey(it, 0) } + } finally { + key.destroy() + } + } + + private fun prekeyPrologue(prekeyId: Long): ByteArray { + require(prekeyId in 0..0xFFFF_FFFFL) + return PREKEY_PROLOGUE_PREFIX + byteArrayOf( + (prekeyId ushr 24).toByte(), + (prekeyId ushr 16).toByte(), + (prekeyId ushr 8).toByte(), + prekeyId.toByte() + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 909ca82b..254a7b62 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -5,6 +5,7 @@ import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.noise.southernstorm.protocol.Noise +import com.bitchat.android.noise.southernstorm.protocol.HandshakeState import java.security.MessageDigest import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap @@ -18,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap * - Channel encryption using password-derived keys * - Peer fingerprint mapping and identity persistence */ -class NoiseEncryptionService(private val context: Context) { +class NoiseEncryptionService( + private val context: Context, + private val identityStateManager: SecureIdentityStateManager = SecureIdentityStateManager(context) +) { companion object { private const val TAG = "NoiseEncryptionService" @@ -42,9 +46,6 @@ class NoiseEncryptionService(private val context: Context) { // Channel encryption for password-protected channels private val channelEncryption = NoiseChannelEncryption() - // Identity management for peer ID rotation support - private val identityStateManager: SecureIdentityStateManager - // Centralized fingerprint management - NO LOCAL STORAGE private val fingerprintManager = PeerFingerprintManager.getInstance() @@ -53,9 +54,6 @@ class NoiseEncryptionService(private val context: Context) { var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake init { - // Initialize identity state manager for persistent storage - identityStateManager = SecureIdentityStateManager(context) - // Load or create keys - temporary placeholders staticIdentityPrivateKey = ByteArray(32) staticIdentityPublicKey = ByteArray(32) @@ -147,6 +145,40 @@ class NoiseEncryptionService(private val context: Context) { return sessionManager.getRemoteStaticKey(peerID) } + fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray { + require(recipientStaticKey.size == 32 && recipientStaticKey.any { it != 0.toByte() }) + val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.INITIATOR) + return try { + state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0) + state.getRemotePublicKey().setPublicKey(recipientStaticKey, 0) + val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8) + state.setPrologue(prologue, 0, prologue.size) + state.start() + val output = ByteArray(payload.size + 128) + output.copyOf(state.writeMessage(output, 0, payload, 0, payload.size)) + } finally { + state.destroy() + } + } + + fun openCourierPayload(ciphertext: ByteArray): Pair { + require(ciphertext.size >= 96) + val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.RESPONDER) + return try { + state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0) + val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8) + state.setPrologue(prologue, 0, prologue.size) + state.start() + val payload = ByteArray(ciphertext.size) + val length = state.readMessage(ciphertext, 0, ciphertext.size, payload, 0) + val senderKey = ByteArray(32) + state.getRemotePublicKey().getPublicKey(senderKey, 0) + senderKey to payload.copyOf(length) + } finally { + state.destroy() + } + } + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? = sessionManager.getAuthenticatedSession(peerID) diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt new file mode 100644 index 00000000..160ac30c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt @@ -0,0 +1,253 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.CourierEnvelope +import java.util.Collections +import java.util.LinkedHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Minimal relay surface used by the bridge courier. + * + * Keeping this adapter boundary small lets the kind-1401 contract be exercised with a + * deterministic in-memory relay in unit tests, without opening a network connection. + */ +internal interface BridgeCourierRelay { + fun subscribe( + filter: NostrFilter, + id: String, + targetRelayUrls: List, + handler: (NostrEvent) -> Unit + ) + + fun unsubscribe(id: String) + + fun hasConnectedRelay(relayUrls: Collection): Boolean + + fun sendEvent( + event: NostrEvent, + relayUrls: List, + onAccepted: () -> Unit + ): Boolean +} + +internal interface BridgeCourierCipher { + fun staticPublicKey(): ByteArray? + + fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray + + fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair = + seal(payload, recipientNoiseKey) to null +} + +private class NostrBridgeCourierRelay( + private val relayManager: NostrRelayManager +) : BridgeCourierRelay { + override fun subscribe( + filter: NostrFilter, + id: String, + targetRelayUrls: List, + handler: (NostrEvent) -> Unit + ) { + relayManager.subscribe( + filter = filter, + id = id, + targetRelayUrls = targetRelayUrls, + handler = handler + ) + } + + override fun unsubscribe(id: String) { + relayManager.unsubscribe(id) + } + + override fun hasConnectedRelay(relayUrls: Collection): Boolean = + relayManager.hasConnectedRelay(relayUrls) + + override fun sendEvent( + event: NostrEvent, + relayUrls: List, + onAccepted: () -> Unit + ): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted, + publicationAllowed = com.bitchat.android.services.bridge.MeshBridgeService.publicationPermit()) +} + +private class EncryptionBridgeCourierCipher( + private val encryptionService: EncryptionService, + private val prekeys: com.bitchat.android.services.bridge.PrekeyManager +) : BridgeCourierCipher { + override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + + override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray = + encryptionService.sealCourierPayload(payload, recipientNoiseKey) + + override fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair { + val id = java.security.MessageDigest.getInstance("SHA-256").digest(payload) + .joinToString("") { "%02x".format(it) } + val sealed = prekeys.seal(payload, id, recipientNoiseKey, recipientAdvertisesPrekeys = true) + return sealed.ciphertext to sealed.prekeyId?.toUInt() + } +} + +/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */ +class BridgeCourierService internal constructor( + private val cipher: BridgeCourierCipher, + private val onEnvelope: (CourierEnvelope) -> Unit, + private val relayManager: BridgeCourierRelay, + private val relayUrls: List = NostrRelayManager.defaultRelays(), + private val clock: () -> Long = System::currentTimeMillis, + private val identityFactory: () -> NostrIdentity = NostrIdentity::generate, + private val enabled: () -> Boolean = { true } +) { + constructor( + context: Context, + encryptionService: EncryptionService, + onEnvelope: (CourierEnvelope) -> Unit + ) : this( + cipher = EncryptionBridgeCourierCipher(encryptionService, com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)), + onEnvelope = onEnvelope, + relayManager = NostrBridgeCourierRelay( + NostrRelayManager.getInstance(context.applicationContext) + ), + enabled = { com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.value } + ) + + companion object { + private const val KIND = 1401 + private const val MAX_ENCODED_BYTES = 20 * 1024 + private const val TAG_REFRESH_INTERVAL_MS = 60 * 60 * 1000L + } + + private val seenEvents = Collections.synchronizedMap( + object : LinkedHashMap(512, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 512 + } + ) + private val subscriptionID = "bridge-courier-drops-${System.identityHashCode(this)}" + @Volatile private var subscribedDay: UInt? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var refreshJob: Job? = null + val isStarted: Boolean get() = subscribedDay != null + + @Synchronized + fun start() { + if (!enabled()) return + val localKey = cipher.staticPublicKey() ?: ByteArray(0) + if (localKey.size == 32) { + val now = clock() + val day = CourierEnvelope.epochDay(now) + if (subscribedDay == day) return + if (subscribedDay != null) relayManager.unsubscribe(subscriptionID) + val tags = listOf(day - 1u, day, day + 1u).map { + CourierEnvelope.recipientTag(localKey, it).toHex() + } + val filter = NostrFilter.Builder() + .kinds(KIND) + .since(now - CourierEnvelope.MAX_LIFETIME_MS) + .limit(100) + .tag("x", *tags.toTypedArray()) + .build() + relayManager.subscribe( + filter = filter, + id = subscriptionID, + targetRelayUrls = relayUrls, + handler = ::handleEvent + ) + subscribedDay = day + if (refreshJob?.isActive != true) { + refreshJob = scope.launch { + while (isActive) { + delay(TAG_REFRESH_INTERVAL_MS) + start() + } + } + } + } + } + + fun deposit( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean { + val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return false + val typed = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + privateMessage + ).encode() + return depositPayload(typed, recipientNoiseKey, onAccepted) + } + + fun depositPayload( + typedPayload: ByteArray, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean { + if (!enabled()) return false + start() + if (!relayManager.hasConnectedRelay(relayUrls)) return false + val sealed = try { cipher.sealWithPrekey(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false } + val now = clock() + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)), + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed.first, + prekeyID = sealed.second, + copies = 1u + ) + val encoded = envelope.encode() ?: return false + if (encoded.size > MAX_ENCODED_BYTES) return false + val identity = try { identityFactory() } catch (_: Exception) { return false } + val event = identity.signEvent( + NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (now / 1000).toInt(), + kind = KIND, + tags = listOf( + listOf("x", envelope.recipientTag.toHex()), + listOf("expiration", (envelope.expiry / 1000u).toString()) + ), + content = Base64.encodeToString(encoded, Base64.NO_WRAP) + ) + ) + if (!enabled()) return false + return relayManager.sendEvent(event, relayUrls, onAccepted) + } + + @Synchronized + fun stop() { + relayManager.unsubscribe(subscriptionID) + subscribedDay = null + refreshJob?.cancel() + refreshJob = null + } + + private fun handleEvent(event: NostrEvent) { + if (!enabled()) return + if (event.kind != KIND || !event.isValidSignature()) return + synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return } + if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return + val encoded = try { Base64.decode(event.content, Base64.DEFAULT) } catch (_: Exception) { return } + if (encoded.size > MAX_ENCODED_BYTES) return + val envelope = CourierEnvelope.decode(encoded) ?: return + val now = clock() + if (envelope.expiry.toLong() <= now || envelope.expiry.toLong() > now + CourierEnvelope.MAX_LIFETIME_MS + 60 * 60 * 1000L) return + val eventTag = event.tags.firstOrNull { it.size > 1 && it[0] == "x" }?.get(1) ?: return + val expiration = event.tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toULongOrNull() ?: return + if (eventTag != envelope.recipientTag.toHex() || expiration != envelope.expiry / 1000u) return + val localKey = cipher.staticPublicKey() ?: return + if (!envelope.matchesRecipient(localKey, now)) return + onEnvelope(envelope) + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt b/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt new file mode 100644 index 00000000..a9c9b3d9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.nostr + +import java.net.URI + +object CustomRelayUrl { + fun normalize(input: String): String? = runCatching { + val uri = URI(input.trim()) + require(uri.scheme.equals("wss", ignoreCase = true)) + require(!uri.host.isNullOrBlank() && uri.userInfo == null && uri.fragment == null && uri.query == null) + require(uri.port == -1 || uri.port in 1..65535) + URI("wss", null, uri.host.lowercase(), uri.port, + uri.path?.takeUnless { it == "/" }, null, null).toASCIIString() + }.getOrNull() +} diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 0edb1fca..4318f1df 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -38,7 +38,10 @@ class LocationNotesManager private constructor() { val pubkey: String, val content: String, val createdAt: Int, - val nickname: String? + val nickname: String?, + val geohash: String, + val expiresAt: Int? = null, + val isUrgent: Boolean = false ) { /** * Display name for the note - matches iOS exactly @@ -97,6 +100,15 @@ class LocationNotesManager private constructor() { // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var liveLocationToken: Long? = null + + init { + scope.launch { + while (isActive) { + delay(60_000) + pruneExpiredNotes() + } + } + } private var subscribeRetryJob: Job? = null private var initialLoadJob: Job? = null @@ -139,7 +151,7 @@ class LocationNotesManager private constructor() { } // Validate geohash (building-level precision: 8 chars) - matches iOS - if (!isValidBuildingGeohash(normalized)) { + if (normalized.length != 8 || !isValidGeohash(normalized)) { Log.w(TAG, "LocationNotesManager rejected an invalid building geohash") return } @@ -172,8 +184,8 @@ class LocationNotesManager private constructor() { /** * Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash */ - private fun isValidBuildingGeohash(geohash: String): Boolean { - if (geohash.length != 8) return false + private fun isValidGeohash(geohash: String): Boolean { + if (geohash.length !in 1..12) return false val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz" return geohash.all { it in base32Chars } } @@ -210,7 +222,7 @@ class LocationNotesManager private constructor() { /** * Send a new location note */ - fun send(content: String, nickname: String?) { + fun send(content: String, nickname: String?, expiresAt: Int? = null, urgent: Boolean = false) { val token = LiveLocationPrivacyGate.captureToken() ?: run { stop() return @@ -275,7 +287,9 @@ class LocationNotesManager private constructor() { content = trimmed, geohash = currentGeohash, senderIdentity = preparedIdentity, - nickname = nickname + nickname = nickname, + expiresAt = expiresAt, + urgent = urgent ) } if (!LiveLocationPrivacyGate.accepts(token)) return@launch @@ -286,7 +300,10 @@ class LocationNotesManager private constructor() { pubkey = preparedEvent.pubkey, content = trimmed, createdAt = preparedEvent.createdAt, - nickname = nickname + nickname = nickname, + geohash = currentGeohash, + expiresAt = expiresAt, + isUrgent = urgent ) if (!noteIDs.contains(preparedEvent.id)) { @@ -301,7 +318,7 @@ class LocationNotesManager private constructor() { } // CRITICAL FIX: Send to geo-specific relays (matching iOS pattern) - // iOS: dependencies.sendEvent(event, relays) + // iOS: dependencies.sendEvent(event, relays, token) val sent = withContext(Dispatchers.IO) { LiveLocationPrivacyGate.runIfAllowed(token) { sendEventFunc?.invoke(preparedEvent, relays, token) @@ -443,6 +460,16 @@ class LocationNotesManager private constructor() { // Extract nickname from tags val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" } val nickname = nicknameTag?.get(1) + val expiresAt = event.tags + .firstOrNull { it.size >= 2 && it[0].equals("expiration", ignoreCase = true) } + ?.get(1) + ?.toIntOrNull() + if (expiresAt != null && expiresAt <= currentEpochSeconds()) return + val urgent = event.tags.any { + it.size >= 2 && + it[0].equals("t", ignoreCase = true) && + it[1].equals("urgent", ignoreCase = true) + } // Create note val note = Note( @@ -450,7 +477,10 @@ class LocationNotesManager private constructor() { pubkey = event.pubkey, content = event.content, createdAt = event.createdAt, - nickname = nickname + nickname = nickname, + geohash = eventGeohash.lowercase(), + expiresAt = expiresAt, + isUrgent = urgent ) // Add to collection @@ -493,6 +523,89 @@ class LocationNotesManager private constructor() { fun clearError() { _errorMessage.value = null } + + fun isOwnNote(note: Note): Boolean { + val current = _geohash.value ?: return false + val deriveIdentity = deriveIdentityFunc ?: return false + return runCatching { deriveIdentity(current).publicKeyHex == note.pubkey }.getOrDefault(false) + } + + fun delete(note: Note): Boolean { + if (!isOwnNote(note)) return false + return deleteEvent(note.id, note.geohash) { + _notes.value = _notes.value.filterNot { it.id == note.id } + } + } + + /** + * Publishes the relay copy of a geohash board post without changing the + * active notes subscription. The callback lets BoardManager retract the + * copy with NIP-09 while the app remains alive. + */ + fun publishBoardBridge( + content: String, + geohash: String, + nickname: String, + expiresAtSeconds: Int, + urgent: Boolean, + onPublished: (String) -> Unit + ) { + val token = LiveLocationPrivacyGate.captureToken() ?: return + val deriveIdentity = deriveIdentityFunc ?: return + val sendEvent = sendEventFunc ?: return + val relays = runCatching { + RelayDirectory.closestRelaysForGeohash(geohash, 5) + }.getOrDefault(emptyList()) + if (relays.isEmpty()) return + scope.launch { + runCatching { + val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) } + val event = NostrProtocol.createGeohashTextNote( + content = content, + geohash = geohash, + senderIdentity = identity, + nickname = nickname, + expiresAt = expiresAtSeconds, + urgent = urgent + ) + withContext(Dispatchers.IO) { sendEvent(event, relays, token) } + onPublished(event.id) + }.onFailure { + Log.e(TAG, "Failed to bridge board post to Nostr: ${it.message}") + } + } + } + + fun deleteEvent(eventID: String, geohash: String, onDeleted: () -> Unit = {}): Boolean { + val token = LiveLocationPrivacyGate.captureToken() ?: return false + val deriveIdentity = deriveIdentityFunc ?: return false + val sendEvent = sendEventFunc ?: return false + val relays = runCatching { + RelayDirectory.closestRelaysForGeohash(geohash, 5) + }.getOrDefault(emptyList()) + if (relays.isEmpty()) return false + scope.launch { + runCatching { + val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) } + val deletion = NostrProtocol.createDeleteEvent(eventID, identity) + withContext(Dispatchers.IO) { sendEvent(deletion, relays, token) } + onDeleted() + }.onFailure { + Log.e(TAG, "Failed to delete Nostr notice: ${it.message}") + } + } + return true + } + + fun pruneExpiredNotes() { + val now = currentEpochSeconds() + _notes.value = _notes.value.filter { note -> + note.expiresAt?.let { it > now } ?: true + } + } + + private fun currentEpochSeconds(): Int = + (System.currentTimeMillis() / 1_000L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() /** * Cancel subscription and clear state diff --git a/app/src/main/java/com/bitchat/android/nostr/MeshMessageIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/MeshMessageIdentity.kt new file mode 100644 index 00000000..553da1de --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/MeshMessageIdentity.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.nostr + +import java.security.MessageDigest + +/** Cross-platform stable identity for a public mesh radio/bridge copy. */ +object MeshMessageIdentity { + fun stableId(senderIdHex: String, timestampMs: Long, content: String): String { + val input = "${senderIdHex.lowercase()}|$timestampMs|${content.trim()}" + return MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(32) + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index a895f612..758a0652 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -92,6 +92,13 @@ object NostrBackgroundRuntime { ) } + fun receiveGatewayEvent(event: NostrEvent, geohash: String) { + if (!initialized || activeGeohash != geohash) return + val selected = (locationChannels.selectedChannel.value as? ChannelID.Location)?.channel ?: return + if (selected.geohash != geohash || !locationChannels.canUseSelectedLocationChannel(selected)) return + eventProcessor.onGeohashMessage(event, geohash) + } + private fun subscribeAccountDm() { val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return subscriptions.subscribeGiftWraps( @@ -147,7 +154,10 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 3_600_000L, limit = 200, id = "geohash-$geohash", - handler = { event -> eventProcessor.onGeohashMessage(event, geohash) }, + handler = { event -> + eventProcessor.onGeohashMessage(event, geohash) + com.bitchat.android.services.bridge.MeshGatewayService.rebroadcastRelayEvent(event, geohash, liveLocationToken) + }, liveLocationToken = liveLocationToken ) subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 0257c756..c5385759 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -241,10 +241,8 @@ class NostrClient private constructor(private val context: Context) { giftWrap: NostrEvent, handler: (content: String, senderNpub: String, timestamp: Int) -> Unit ) { - // Age filtering (24h + 15min buffer for randomized timestamps) - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) { // 48 hours + 15 minutes - Log.v(TAG, "Ignoring old private message") + if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) { + Log.v(TAG, "Ignoring private message with implausible gift-wrap created_at") return } @@ -254,6 +252,10 @@ class NostrClient private constructor(private val context: Context) { val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult != null) { val (content, senderPubkey, timestamp) = decryptResult + if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(timestamp)) { + Log.w(TAG, "Dropping private message with implausible rumor timestamp") + return + } // Convert sender pubkey to npub val senderNpub = try { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4125d5ca..b483ad75 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -60,8 +60,10 @@ class NostrDirectMessageHandler( try { if (dedupe(giftWrap.id)) return@launch - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) return@launch // 48 hours + 15 mins + if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) { + Log.v(TAG, "Ignoring gift wrap with implausible created_at") + return@launch + } val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult == null) { @@ -70,6 +72,10 @@ class NostrDirectMessageHandler( } val (content, rawSenderPubkey, rumorTimestamp) = decryptResult + if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumorTimestamp)) { + Log.w(TAG, "Dropping Nostr DM with implausible rumor timestamp") + return@launch + } val senderPubkey = rawSenderPubkey.lowercase() // If sender is blocked for geohash contexts, drop any events from this pubkey @@ -188,6 +194,7 @@ class NostrDirectMessageHandler( } NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) + com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID) withContext(Dispatchers.Main) { updateDeliveryStatus( messageId, @@ -197,6 +204,7 @@ class NostrDirectMessageHandler( } NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) + com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID) withContext(Dispatchers.Main) { updateDeliveryStatus( messageId, @@ -243,6 +251,9 @@ class NostrDirectMessageHandler( NoisePayloadType.VERIFY_CHALLENGE, NoisePayloadType.VERIFY_RESPONSE, NoisePayloadType.VOICE_FRAME, + NoisePayloadType.GROUP_INVITE, + NoisePayloadType.GROUP_KEY_UPDATE, + NoisePayloadType.VOUCH, NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 92752b17..483eeb86 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -210,10 +210,12 @@ data class NostrEvent( object NostrKind { const val METADATA = 0 const val TEXT_NOTE = 1 + const val DELETION = 5 const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned) const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned) const val SEAL = 13 // NIP-17 sealed event const val GIFT_WRAP = 1059 // NIP-17 gift wrap + const val COURIER_DROP = 1401 // Opaque bridge courier envelope const val EPHEMERAL_EVENT = 20000 // For geohash channels const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index df67822f..ba7ebac0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -69,6 +69,28 @@ data class NostrFilter( limit = limit ) } + + fun bridgeRendezvous( + cells: Collection, + since: Long? = null, + limit: Int = 200 + ): NostrFilter = NostrFilter( + kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("r" to cells.toList()), + limit = limit + ) + + fun courierDrops( + recipientTagsHex: Collection, + since: Long? = null, + limit: Int = 100 + ): NostrFilter = NostrFilter( + kinds = listOf(NostrKind.COURIER_DROP), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("x" to recipientTagsHex.toList()), + limit = limit + ) /** * Create filter for text notes from specific authors diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 665bfd50..ebe32e74 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -176,6 +176,39 @@ object NostrIdentityBridge { Log.d(TAG, "Used fallback geohash identity derivation") return fallbackIdentity } + + /** + * Derive the iOS-compatible bridge rendezvous identity. The domain label + * prevents linking it to geohash-chat identity and the iteration is + * encoded big-endian, matching CryptoKit's UInt32.bigEndian bytes. + */ + fun deriveBridgeIdentity(cell: String, context: Context): NostrIdentity { + val label = "bridge|$cell" + geohashIdentityCache[label]?.let { return it } + val stateManager = SecureIdentityStateManager(context) + val seed = getOrCreateDeviceSeed(stateManager) + val message = label.toByteArray(Charsets.UTF_8) + + for (iteration in 0 until 10) { + val input = message + byteArrayOf( + (iteration ushr 24).toByte(), + (iteration ushr 16).toByte(), + (iteration ushr 8).toByte(), + iteration.toByte() + ) + val candidate = hmacSha256(seed, input).toHexStringLocal() + if (NostrCrypto.isValidPrivateKey(candidate)) { + return NostrIdentity.fromPrivateKey(candidate).also { + geohashIdentityCache[label] = it + } + } + } + + val fallback = MessageDigest.getInstance("SHA-256").digest(seed + message) + return NostrIdentity.fromPrivateKey(fallback.toHexStringLocal()).also { + geohashIdentityCache[label] = it + } + } /** * Generate candidate key for a specific iteration (matches iOS implementation) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt index dd9e16d1..3c95a9c7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -16,14 +16,16 @@ internal class NostrPendingEventQueue( data class Delivery( val queueId: Long, val event: NostrEvent, - val liveLocationToken: Long? + val liveLocationToken: Long?, + val publicationAllowed: () -> Boolean ) private data class Entry( val queueId: Long, val event: NostrEvent, val pendingRelayUrls: MutableSet, - val liveLocationToken: Long? + val liveLocationToken: Long?, + val publicationAllowed: () -> Boolean ) private val lock = Any() @@ -33,7 +35,8 @@ internal class NostrPendingEventQueue( fun enqueue( event: NostrEvent, relayUrls: Collection, - liveLocationToken: Long? + liveLocationToken: Long?, + publicationAllowed: () -> Boolean = { true } ): Long? { val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } if (pendingRelays.isEmpty()) return null @@ -46,7 +49,8 @@ internal class NostrPendingEventQueue( queueId = queueId, event = event, pendingRelayUrls = pendingRelays, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + publicationAllowed = publicationAllowed ) ) queueId @@ -54,10 +58,11 @@ internal class NostrPendingEventQueue( } fun pendingForRelay(relayUrl: String): List = synchronized(lock) { + entries.removeAll { !it.publicationAllowed() } entries .asSequence() .filter { relayUrl in it.pendingRelayUrls } - .map { Delivery(it.queueId, it.event, it.liveLocationToken) } + .map { Delivery(it.queueId, it.event, it.liveLocationToken, it.publicationAllowed) } .toList() } @@ -74,6 +79,11 @@ internal class NostrPendingEventQueue( } } + fun removeRelay(relayUrl: String) = synchronized(lock) { + entries.forEach { it.pendingRelayUrls.remove(relayUrl) } + entries.removeAll { it.pendingRelayUrls.isEmpty() } + } + fun removeLiveLocationEvents() { synchronized(lock) { entries.removeAll { it.liveLocationToken != null } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 7463a378..0124413a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -65,6 +65,12 @@ object NostrProtocol { Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...") return try { + val recipientTags = listOf(listOf("p", recipientIdentity.publicKeyHex)) + if (giftWrap.content.toByteArray(Charsets.UTF_8).size > 64 * 1024 || + giftWrap.kind != NostrKind.GIFT_WRAP || giftWrap.tags != recipientTags || + !NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt) || + !giftWrap.isValidSignature() + ) return null // 1. Unwrap the gift wrap val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex) ?: run { @@ -74,7 +80,7 @@ object NostrProtocol { Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...") - if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) { + if (seal.kind != NostrKind.SEAL || seal.tags.isNotEmpty() || !seal.isValidSignature()) { Log.w(TAG, "❌ Invalid NIP-17 seal signature") return null } @@ -86,7 +92,10 @@ object NostrProtocol { return null } - if (seal.pubkey != rumor.pubkey) { + if (seal.pubkey != rumor.pubkey || rumor.kind != NostrKind.DIRECT_MESSAGE || + (rumor.tags.isNotEmpty() && rumor.tags != recipientTags) || rumor.sig != null || + !NostrTimestampPolicy.isPlausibleRumorTimestamp(rumor.createdAt) + ) { Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey") return null } @@ -108,7 +117,9 @@ object NostrProtocol { content: String, geohash: String, senderIdentity: NostrIdentity, - nickname: String? = null + nickname: String? = null, + expiresAt: Int? = null, + urgent: Boolean = false ): NostrEvent = withContext(Dispatchers.Default) { val tags = mutableListOf>() tags.add(listOf("g", geohash)) @@ -116,6 +127,12 @@ object NostrProtocol { if (!nickname.isNullOrEmpty()) { tags.add(listOf("n", nickname)) } + expiresAt?.let { + tags.add(listOf("expiration", it.toString())) + } + if (urgent) { + tags.add(listOf("t", "urgent")) + } val event = NostrEvent( pubkey = senderIdentity.publicKeyHex, @@ -128,6 +145,21 @@ object NostrProtocol { return@withContext senderIdentity.signEvent(event) } + /** Create a NIP-09 deletion request signed by the original event identity. */ + suspend fun createDeleteEvent( + eventID: String, + senderIdentity: NostrIdentity + ): NostrEvent = withContext(Dispatchers.Default) { + val event = NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DELETION, + tags = listOf(listOf("e", eventID)), + content = "" + ) + senderIdentity.signEvent(event) + } + /** * Create a geohash-scoped presence event (kind 20001) * Has no content and no nickname, used for participant counting @@ -212,6 +244,73 @@ object NostrProtocol { return@withContext senderIdentity.signEvent(event) } + + /** iOS-compatible public mesh event on a bridge rendezvous cell. */ + fun createBridgeMeshEvent( + content: String, + cell: String, + senderIdentity: NostrIdentity, + nickname: String? = null, + meshSenderId: String? = null, + meshTimestampMs: Long? = null + ): NostrEvent { + val tags = mutableListOf>(listOf("r", cell)) + nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) } + val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() } + if (sender != null && meshTimestampMs != null) { + tags += listOf( + "m", + MeshMessageIdentity.stableId(sender, meshTimestampMs, content), + sender, + meshTimestampMs.toString() + ) + } + return senderIdentity.signEvent( + NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = tags, + content = content + ) + ) + } + + /** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */ + fun createBridgePresenceEvent( + cell: String, + senderIdentity: NostrIdentity + ): NostrEvent = senderIdentity.signEvent( + NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.GEOHASH_PRESENCE, + tags = listOf(listOf("r", cell)), + content = "" + ) + ) + + /** + * Opaque relay drop. Callers use a throwaway identity so deposits cannot + * be linked by their Nostr publisher key. + */ + fun createCourierDropEvent( + envelope: ByteArray, + recipientTagHex: String, + expiresAtMs: Long, + senderIdentity: NostrIdentity + ): NostrEvent = senderIdentity.signEvent( + NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.COURIER_DROP, + tags = listOf( + listOf("x", recipientTagHex), + listOf("expiration", (expiresAtMs / 1000).toString()) + ), + content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP) + ) + ) // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 81c1cac6..534d4bbd 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -35,6 +35,7 @@ class NostrRelayManager private constructor() { */ fun getInstance(context: android.content.Context): NostrRelayManager { shared.appContext = context.applicationContext + shared.loadCustomRelays(context.applicationContext) return shared } @@ -55,7 +56,7 @@ class NostrRelayManager private constructor() { pendingGiftWrapIDs.add(id) } - fun defaultRelays(): List = DEFAULT_RELAYS + fun defaultRelays(): List = (DEFAULT_RELAYS + shared.customRelays.value).distinct() } /** @@ -73,12 +74,82 @@ class NostrRelayManager private constructor() { var nextReconnectTime: Long? = null ) + private val _customRelays = MutableStateFlow>(emptyList()) + val customRelays: StateFlow> = _customRelays.asStateFlow() + private var customRelaysLoaded = false + private val removedCustomRelayUrls = java.util.concurrent.ConcurrentHashMap.newKeySet() + + @Synchronized + private fun loadCustomRelays(context: android.content.Context) { + if (customRelaysLoaded) return + customRelaysLoaded = true + val urls = context.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE) + .getStringSet("urls", emptySet()).orEmpty().mapNotNull(CustomRelayUrl::normalize).distinct().take(20) + _customRelays.value = urls + nonLiveRelayUrls.addAll(urls) + ensureConnectionsFor(urls.toSet()) + } + + @Synchronized + fun addCustomRelay(input: String): Boolean { + val url = CustomRelayUrl.normalize(input) ?: return false + if (url in defaultRelays()) return true + if (_customRelays.value.size >= 20) return false + val previousDefaults = defaultRelays().toSet() + removedCustomRelayUrls.remove(url) + _customRelays.value = _customRelays.value + url + persistCustomRelays() + nonLiveRelayUrls.add(url) + activeSubscriptions.replaceAll { _, subscription -> + if (subscription.targetRelayUrls == previousDefaults) { + subscription.copy(targetRelayUrls = previousDefaults + url) + } else subscription + } + ensureConnectionsFor(setOf(url)) + return true + } + + @Synchronized + fun removeCustomRelay(url: String) { + if (url !in _customRelays.value) return + removedCustomRelayUrls.add(url) + messageQueue.removeRelay(url) + _customRelays.value = _customRelays.value - url + persistCustomRelays() + activeSubscriptions.replaceAll { _, subscription -> + subscription.copy(targetRelayUrls = subscription.targetRelayUrls?.minus(url)) + } + reconnectJobs.remove(url)?.cancel() + connections.remove(url)?.close(1000, "Relay removed") + subscriptions.remove(url) + nonLiveRelayUrls.remove(url) + synchronized(relaysList) { relaysList.removeAll { it.url == url } } + updateRelaysList() + updateConnectionStatus() + } + + fun clearCustomRelays() { + _customRelays.value.toList().forEach(::removeCustomRelay) + check(appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE) + ?.edit()?.clear()?.commit() != false) + } + + private fun persistCustomRelays() { + appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE) + ?.edit()?.putStringSet("urls", _customRelays.value.toSet())?.apply() + } + // Published state private val _relays = MutableStateFlow>(emptyList()) val relays: StateFlow> = _relays.asStateFlow() private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private data class PendingAcceptance( + val callback: () -> Unit, + val pendingRelays: MutableSet + ) + private val eventAcceptanceHandlers = java.util.concurrent.ConcurrentHashMap() // Internal state private val relaysList = mutableListOf() @@ -226,23 +297,24 @@ class NostrRelayManager private constructor() { geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + publicationAllowed: () -> Boolean = { true } ) { - if (!isNetworkActionAllowed(liveLocationToken)) return + if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return ensureGeohashRelaysConnected( geohash, nRelays, includeDefaults, liveLocationToken ) - if (!isNetworkActionAllowed(liveLocationToken)) return + if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { Log.w(TAG, "No target relays for geohash event; falling back to defaults") - sendEvent(event, Companion.defaultRelays(), liveLocationToken) + sendEvent(event, Companion.defaultRelays(), liveLocationToken, publicationAllowed = publicationAllowed) return } - sendEvent(event, relayUrls, liveLocationToken) + sendEvent(event, relayUrls, liveLocationToken, publicationAllowed = publicationAllowed) } // --- Internal helpers --- @@ -446,32 +518,49 @@ class NostrRelayManager private constructor() { fun sendEvent( event: NostrEvent, relayUrls: List? = null, - liveLocationToken: Long? = null - ) { + liveLocationToken: Long? = null, + onAccepted: (() -> Unit)? = null, + publicationAllowed: () -> Boolean = { true } + ): Boolean { + if (!publicationAllowed()) return false val targetRelays = (relayUrls ?: relaysList.map { it.url }) .filter { it.isNotBlank() } .distinct() - if (targetRelays.isEmpty()) return + if (targetRelays.isEmpty()) return false + var enqueued = false val queued = runNetworkAction(liveLocationToken) { val queueId = messageQueue.enqueue( event = event, relayUrls = targetRelays, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + publicationAllowed = publicationAllowed ) ?: return@runNetworkAction + enqueued = true + if (onAccepted != null) { + eventAcceptanceHandlers[event.id] = PendingAcceptance( + onAccepted, + targetRelays.map { it.trim().trimEnd('/') }.toMutableSet() + ) + } scope.launch { if (!isNetworkActionAllowed(liveLocationToken)) return@launch targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) { + if (sendToRelay(event, webSocket, relayUrl, liveLocationToken, publicationAllowed)) { messageQueue.markDelivered(queueId, relayUrl) } } } } } - if (!queued) return + return queued && enqueued + } + + fun hasConnectedRelay(relayUrls: Collection): Boolean { + val targets = relayUrls.map { it.trim().trimEnd('/') }.toSet() + return relaysList.any { it.isConnected && it.url.trim().trimEnd('/') in targets } } /** @@ -667,6 +756,7 @@ class NostrRelayManager private constructor() { // Clear any queued messages waiting to be sent messageQueue.clear() + eventAcceptanceHandlers.clear() Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") } catch (e: Exception) { @@ -838,7 +928,7 @@ class NostrRelayManager private constructor() { urlString: String, liveLocationToken: Long? = null ) { - if (!desiredConnected.get()) return + if (!desiredConnected.get() || urlString in removedCustomRelayUrls) return val connectionToken = liveLocationToken ?.takeIf { urlString !in nonLiveRelayUrls } if (!isNetworkActionAllowed(connectionToken)) return @@ -853,6 +943,7 @@ class NostrRelayManager private constructor() { .build() val started = runNetworkAction(connectionToken) { + if (urlString in removedCustomRelayUrls) return@runNetworkAction val webSocket = httpClient.newWebSocket( request, RelayWebSocketListener(urlString, connectionToken) @@ -860,7 +951,7 @@ class NostrRelayManager private constructor() { val existing = connections.putIfAbsent(urlString, webSocket) when { existing != null -> webSocket.close(1000, "Duplicate connection") - !desiredConnected.get() -> { + !desiredConnected.get() || urlString in removedCustomRelayUrls -> { connections.remove(urlString, webSocket) webSocket.close(1000, "Connection no longer desired") } @@ -878,16 +969,17 @@ class NostrRelayManager private constructor() { event: NostrEvent, webSocket: WebSocket, relayUrl: String, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + publicationAllowed: () -> Boolean = { true } ): Boolean { - if (!isNetworkActionAllowed(liveLocationToken)) return false + if (!publicationAllowed() || relayUrl in removedCustomRelayUrls || !isNetworkActionAllowed(liveLocationToken)) return false return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(liveLocationToken) { - success = webSocket.send(message) + if (publicationAllowed() && relayUrl !in removedCustomRelayUrls) success = webSocket.send(message) } if (success) { // Update relay stats @@ -960,8 +1052,14 @@ class NostrRelayManager private constructor() { is NostrResponse.Ok -> { val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) if (!response.accepted) { + eventAcceptanceHandlers[response.eventId]?.let { pending -> + pending.pendingRelays.remove(relayUrl.trim().trimEnd('/')) + if (pending.pendingRelays.isEmpty()) eventAcceptanceHandlers.remove(response.eventId, pending) + } val level = if (wasGiftWrap) Log.WARN else Log.ERROR Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") + } else { + eventAcceptanceHandlers.remove(response.eventId)?.callback?.invoke() } } @@ -1118,7 +1216,7 @@ class NostrRelayManager private constructor() { ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - if (!desiredConnected.get() || + if (!desiredConnected.get() || relayUrl in removedCustomRelayUrls || connections[relayUrl] !== webSocket || !isNetworkActionAllowed(liveLocationToken) ) { @@ -1140,7 +1238,8 @@ class NostrRelayManager private constructor() { delivery.event, webSocket, relayUrl, - delivery.liveLocationToken + delivery.liveLocationToken, + delivery.publicationAllowed ) ) { messageQueue.markDelivered(delivery.queueId, relayUrl) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt new file mode 100644 index 00000000..1daca641 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants + +/** + * Client-side timestamp windows for inbound Nostr DMs. + * + * Mirrors iOS `NostrInboundPipeline.isPlausibleRumorTimestamp`: a relay that + * ignores the subscription `since` filter — or replays archived events — must + * not inject stale or future-dated DMs. The inner rumor timestamp is the + * sender's true send time; only the outer gift wrap is NIP-17-randomized. + */ +object NostrTimestampPolicy { + + /** + * Accept an inner rumor `created_at` inside + * `[now − lookback − skew, now + skew]`. + */ + fun isPlausibleRumorTimestamp( + tsSeconds: Int, + nowSeconds: Long = System.currentTimeMillis() / 1000L + ): Boolean { + val age = nowSeconds - tsSeconds.toLong() + val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS + return age >= -skew && age <= lookback + skew + } + + /** + * Accept an outer gift-wrap `created_at` that is not in the far future and + * not older than the NIP-17 randomization ceiling plus skew. + */ + fun isAcceptableGiftWrapTimestamp( + createdAtSeconds: Int, + nowSeconds: Long = System.currentTimeMillis() / 1000L + ): Boolean { + val age = nowSeconds - createdAtSeconds.toLong() + val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + val maxAge = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS + return age >= -skew && age <= maxAge + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index f39324fd..5dd1d49f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -46,6 +46,11 @@ class NostrTransport( // MARK: - Transport Interface Methods val myPeerID: String get() = senderPeerID + + fun canDeliverPromptly(): Boolean = try { + NostrRelayManager.getInstance(context) + .hasConnectedRelay(NostrRelayManager.defaultRelays()) + } catch (_: Exception) { false } fun sendPrivateMessage( content: String, diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 6a0445b0..9e08b49c 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -14,12 +14,19 @@ enum class MessageType(val value: UByte) { ANNOUNCE(0x01u), MESSAGE(0x02u), // All user messages (private and broadcast) LEAVE(0x03u), + COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope NOISE_HANDSHAKE(0x10u), // Noise handshake NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message 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 + BOARD_POST(0x23u), + GROUP_MESSAGE(0x25u), // Opaque private-group ciphertext broadcast + PREKEY_BUNDLE(0x24u), // Signed batch of one-time courier prekeys + NOSTR_CARRIER(0x28u), // Signed bridge/gateway event carrier + PING(MeshDiagnosticsConstants.PING_TYPE), + PONG(MeshDiagnosticsConstants.PONG_TYPE); companion object { fun fromValue(value: UByte): MessageType? { diff --git a/app/src/main/java/com/bitchat/android/protocol/MeshDiagnosticsConstants.kt b/app/src/main/java/com/bitchat/android/protocol/MeshDiagnosticsConstants.kt new file mode 100644 index 00000000..62935dbd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/MeshDiagnosticsConstants.kt @@ -0,0 +1,15 @@ +package com.bitchat.android.protocol + +object MeshDiagnosticsConstants { + val PING_TYPE: UByte = 0x26u + val PONG_TYPE: UByte = 0x27u + val TTL: UByte = 7u + const val NONCE_SIZE = 8 + const val PAYLOAD_SIZE = NONCE_SIZE + 1 + const val TIMEOUT_MILLIS = 10_000L + const val INBOUND_RATE_LIMIT = 5 + const val INBOUND_RATE_WINDOW_MILLIS = 10_000L + const val RELAY_JITTER_MIN_MILLIS = 20L + const val RELAY_JITTER_MAX_MILLIS = 60L + const val CAPABILITY_BIT = 6 +} diff --git a/app/src/main/java/com/bitchat/android/protocol/MeshPingPayload.kt b/app/src/main/java/com/bitchat/android/protocol/MeshPingPayload.kt new file mode 100644 index 00000000..1378dbb7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/MeshPingPayload.kt @@ -0,0 +1,46 @@ +package com.bitchat.android.protocol + +import java.security.SecureRandom + +/** + * iOS-compatible mesh diagnostics payload. + * + * Wire format: 8-byte nonce followed by the TTL used to launch the ping. + * Decoding deliberately accepts trailing bytes for forward compatibility. + */ +data class MeshPingPayload( + val nonce: ByteArray, + val originTtl: UByte, +) { + init { + require(nonce.size == MeshDiagnosticsConstants.NONCE_SIZE) { + "Mesh ping nonce must be ${MeshDiagnosticsConstants.NONCE_SIZE} bytes" + } + } + + fun encode(): ByteArray = nonce + byteArrayOf(originTtl.toByte()) + + fun hopCount(receivedTtl: UByte): Int = + (originTtl.toInt() - receivedTtl.toInt() + 1).coerceAtLeast(1) + + override fun equals(other: Any?): Boolean = + other is MeshPingPayload && nonce.contentEquals(other.nonce) && originTtl == other.originTtl + + override fun hashCode(): Int = 31 * nonce.contentHashCode() + originTtl.hashCode() + + companion object { + fun create(originTtl: UByte): MeshPingPayload = + MeshPingPayload( + ByteArray(MeshDiagnosticsConstants.NONCE_SIZE).also(SecureRandom()::nextBytes), + originTtl, + ) + + fun decode(data: ByteArray): MeshPingPayload? { + if (data.size < MeshDiagnosticsConstants.PAYLOAD_SIZE) return null + return MeshPingPayload( + data.copyOfRange(0, MeshDiagnosticsConstants.NONCE_SIZE), + data[MeshDiagnosticsConstants.NONCE_SIZE].toUByte(), + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index 67a7a1d8..ab0baeb6 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -44,6 +44,9 @@ object TransportBridgeService { * Send a packet to a specific peer via this transport (optional). */ fun sendToPeer(peerID: String, packet: BitchatPacket) { } + + /** Send directly and report whether the transport accepted the write. */ + fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean = false } private val transports = ConcurrentHashMap() @@ -200,6 +203,19 @@ object TransportBridgeService { } } + fun sendToPeerFromLocalAndReport(peerID: String, packet: BitchatPacket): Boolean { + val targets = transports.toMap() + if (targets.isEmpty()) return false + return targets.values.fold(false) { accepted, layer -> + try { + layer.sendToPeerAndReport(peerID, packet) || accepted + } catch (e: Exception) { + Log.e(TAG, "Failed to send local peer packet: ${e.message}") + accepted + } + } + } + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? { if (packet.ttl == 0u.toUByte()) { Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c4f52675..86aa2f20 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -188,6 +188,11 @@ object AppStateStore { fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { + if (!msg.isBridged) { + _publicMessages.value = _publicMessages.value.filterNot { + it.isBridged && it.bridgeRadioMessageIdHint == msg.id + } + } val publicKey = publicMessageKey(msg) if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return seenMessageIds.add(msg.id) @@ -196,6 +201,10 @@ object AppStateStore { } } + fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) { + _publicMessages.value.any { !it.isBridged && it.id == messageId } + } + /** Replace a live media row by ID, or append it if the row was not admitted yet. */ fun upsertPublicMessage(msg: BitchatMessage) { synchronized(this) { @@ -263,6 +272,22 @@ object AppStateStore { * Persists an incoming private message before it is admitted to UI, unread, haptic, or * notification state. Transport callbacks invoke this from their background worker. */ + suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean { + val repository = synchronized(this) { + if (privateConversationWritesSuspended) return false + conversationRepository + } ?: return false + return repository.hasPrivateTextReceipt(message) + } + + suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState { + val repository = synchronized(this) { + if (privateConversationWritesSuspended) return PrivateMediaReceiptState.UNAVAILABLE + conversationRepository + } ?: return PrivateMediaReceiptState.UNAVAILABLE + return repository.privateMediaReceiptState(messageID) + } + suspend fun addPrivateMessageDurably( peerID: String, msg: BitchatMessage, @@ -408,13 +433,27 @@ 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 - is DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + is DeliveryStatus.Queued -> 2 + is DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 is DeliveryStatus.Failed -> 0 } diff --git a/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt b/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt new file mode 100644 index 00000000..f8c4e5bf --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt @@ -0,0 +1,15 @@ +package com.bitchat.android.services + +import java.net.URI + +/** Parses explicit channel invitations; never requests or infers device location. */ +object ChannelInvitation { + private val geohash = Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}") + fun decode(link: String): String? = runCatching { + val uri = URI(link) + require(uri.scheme == "bitchat" && uri.host == "geohash" && uri.query == null && uri.fragment == null && uri.userInfo == null && uri.port == -1) + val cell = uri.path.removePrefix("/").lowercase() + cell.takeIf(geohash::matches) + }.getOrNull() + fun link(cell: String): String? = cell.lowercase().takeIf(geohash::matches)?.let { "bitchat://geohash/$it" } +} diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index 7c733958..a025c1b8 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -72,6 +72,9 @@ object ContactDirectory { favorite != null -> favorite.peerNoisePublicKey ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) -> ContactIdentityResolver.bytesFromHex(peerOrConversationID) + contactFingerprint != null -> cachedNoiseKeyForFingerprint(contactFingerprint) + ContactIdentityResolver.isMeshPeerId(peerOrConversationID) -> + noiseKeyForAlias(peerOrConversationID) else -> null } val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) } @@ -156,6 +159,18 @@ object ContactDirectory { } } + /** + * A contact conversation ID contains the SHA-256 fingerprint of the Noise key, whose + * first 16 hex characters are the stable mesh peer ID. Recovering that cached key keeps + * offline routing viable after the outbox has canonicalized a peer ID to `contact_…`. + * Recompute the fingerprint before returning it so stale or mismatched cache entries cannot + * redirect a private message. + */ + private fun cachedNoiseKeyForFingerprint(fingerprint: String): ByteArray? = + cachedNoiseKey(fingerprint.take(16))?.takeIf { + ContactIdentityResolver.fingerprintHex(it).equals(fingerprint, ignoreCase = true) + } + private fun cachedFingerprintNickname(fingerprint: String): String? { val context = appContext ?: return null return try { diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index bc474158..0b5ab27a 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -27,6 +27,8 @@ import java.util.Date import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean +enum class PrivateMediaReceiptState { ABSENT, ACCEPTED, TOMBSTONED, UNAVAILABLE } + /** * Process-wide, serialized persistence for private conversations. * @@ -141,6 +143,16 @@ class ConversationRepository internal constructor( } } + suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean = withContext(dispatcher) { + try { database.hasPrivateTextReceipt(message) } catch (_: Exception) { false } + } + + suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState = withContext(dispatcher) { + try { database.privateMediaReceiptState(messageID) } catch (_: Exception) { + PrivateMediaReceiptState.UNAVAILABLE + } + } + suspend fun upsertMessageAndWait( conversationID: String, aliases: Set, @@ -743,6 +755,33 @@ internal class ConversationDatabase( } } + fun hasPrivateTextReceipt(incoming: BitchatMessage): Boolean { + if (!incoming.isPrivate || incoming.type != BitchatMessageType.Message) return false + if (isDeletedMessageLocked(readableDatabase, incoming.id)) return true + readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?", + arrayOf(incoming.id), null, null, null).use { cursor -> + if (!cursor.moveToFirst()) return false + val stored = cursor.toMessage() + return stored.type == BitchatMessageType.Message && stored.content == incoming.content && + stored.senderPeerID == incoming.senderPeerID + } + } + + fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState { + if (!com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(messageID)) { + return PrivateMediaReceiptState.UNAVAILABLE + } + if (isDeletedMessageLocked(readableDatabase, messageID)) return PrivateMediaReceiptState.TOMBSTONED + readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?", + arrayOf(messageID), null, null, null).use { cursor -> + if (!cursor.moveToFirst()) return PrivateMediaReceiptState.ABSENT + val message = cursor.toMessage() + return if (message.type != BitchatMessageType.Message && File(message.content).isFile) { + PrivateMediaReceiptState.ACCEPTED + } else PrivateMediaReceiptState.UNAVAILABLE + } + } + private fun loadDeletedMessageIDs(): Set { readableDatabase.query( "deleted_private_messages", @@ -1684,6 +1723,7 @@ internal class ConversationDatabase( null -> put("delivery_type", 0) DeliveryStatus.Sending -> put("delivery_type", 1) DeliveryStatus.Sent -> put("delivery_type", 2) + DeliveryStatus.Queued -> put("delivery_type", 7) is DeliveryStatus.Delivered -> { put("delivery_type", 3) if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text") @@ -1710,10 +1750,11 @@ internal class ConversationDatabase( null -> 0 is DeliveryStatus.Failed -> 0 DeliveryStatus.Sending -> 1 - DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + DeliveryStatus.Queued -> 2 + DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 } private fun Cursor.toMessage(): BitchatMessage { @@ -1781,6 +1822,7 @@ internal class ConversationDatabase( reached = nullableInt("delivery_reached") ?: 0, total = nullableInt("delivery_total") ?: 0 ) + 7 -> DeliveryStatus.Queued else -> null } diff --git a/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt b/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt new file mode 100644 index 00000000..687de6bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.services + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.nio.file.StandardCopyOption + +/** Keystore-sealed persistence for private messages awaiting final acknowledgement. */ +internal class MessageOutboxStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS) +) { + data class Entry( + val content: String, + val nickname: String, + val messageID: String, + val enqueuedAtMs: Long, + var sendAttempts: Int = 0, + var lastAttemptAtMs: Long = 0, + var bridgeDeposited: Boolean = false, + var lastBridgeAttemptAtMs: Long = 0, + val depositedCourierKeys: MutableSet = mutableSetOf() + ) + + companion object { + private const val KEY_ALIAS = "bitchat_message_outbox_v1" + private val AAD = "bitchat-message-outbox-v1".toByteArray(Charsets.UTF_8) + } + + private val gson = Gson() + private val file = File(context.applicationContext.filesDir, "message-outbox.sealed") + + @Synchronized + fun load(): MutableMap> = try { + if (!file.exists()) return mutableMapOf() + val plaintext = cipher.decrypt(file.readBytes(), AAD) + val type = object : TypeToken>>() {}.type + gson.fromJson>>(plaintext.toString(Charsets.UTF_8), type) + ?: mutableMapOf() + } catch (_: Exception) { + mutableMapOf() + } + + @Synchronized + fun save(outbox: Map>) { + if (outbox.values.all { it.isEmpty() }) { + file.delete() + return + } + val plaintext = gson.toJson(outbox).toByteArray(Charsets.UTF_8) + val encrypted = cipher.encrypt(plaintext, AAD) + val temporary = File(file.parentFile, "${file.name}.tmp") + temporary.writeBytes(encrypted) + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (e: Exception) { + temporary.delete() + throw IllegalStateException("Failed to persist message outbox", e) + } + } + + @Synchronized + fun wipe() { + file.delete() + cipher.destroyKey() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index dc6ad4d2..ee83cf07 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -31,13 +31,6 @@ class MessageRouter private constructor( DROPPED } - private data class QueuedMessage( - val content: String, - val nickname: String, - val messageID: String, - val enqueuedAtMs: Long - ) - private data class ConversationRetry( val handshakeAttempts: Int, val nextHandshakeAttemptAtMs: Long @@ -48,10 +41,15 @@ class MessageRouter private constructor( private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER + private const val MAX_COURIERS_PER_MESSAGE = AppConstants.Router.MAX_COURIERS_PER_MESSAGE + private const val OUTBOX_MAX_TOTAL = 1_000 + private const val OUTBOX_MAX_SEND_ATTEMPTS = 8 + private const val BRIDGE_RETRY_COOLDOWN_MS = 30 * 60 * 1000L private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS @Volatile private var INSTANCE: MessageRouter? = null internal var disableSchedulerForTesting = false + internal var outboxStoreFactory: (Context) -> MessageOutboxStore = ::MessageOutboxStore fun tryGetInstance(): MessageRouter? = INSTANCE fun getInstance(context: Context, mesh: MeshService): MessageRouter { val instance = INSTANCE ?: synchronized(this) { @@ -78,10 +76,19 @@ class MessageRouter private constructor( INSTANCE?.schedulerScope?.cancel() INSTANCE = null } + + fun panicClear(context: Context) { + val instance = INSTANCE + if (instance != null) instance.clearAll() + else outboxStoreFactory(context.applicationContext).wipe() + } } // Outbox: conversationID -> queued messages, oldest first - private val outbox = ConcurrentHashMap>() + private val outboxStore = outboxStoreFactory(context) + private val outbox = ConcurrentHashMap>().apply { + putAll(outboxStore.load()) + } // Per-conversation handshake retry state for queued messages private val retryState = ConcurrentHashMap() @@ -99,9 +106,13 @@ class MessageRouter private constructor( startOutboxScheduler() } + @Synchronized fun clearAll() { + schedulerJob?.cancel() + schedulerJob = null outbox.clear() retryState.clear() + outboxStore.wipe() Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state") } @@ -134,17 +145,23 @@ class MessageRouter private constructor( } val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true + val entry = MessageOutboxStore.Entry(content, recipientNickname, messageID, clock()) + enqueue(conversationID, entry) if (meshTarget != null && isReady(mesh, meshTarget)) { Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID) + markAttempt(conversationID, messageID) return RouteResult.MESH } else if (canSendViaNostr(nostrTarget)) { Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…") nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID) - return RouteResult.NOSTR + markAttempt(conversationID, messageID) + val prompt = canDeliverViaNostrPromptly() + if (!prompt) attemptCourierDeposit(conversationID, entry) + return if (prompt) RouteResult.NOSTR else RouteResult.QUEUED } else { Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") - enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock())) + attemptCourierDeposit(conversationID, entry) Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) } return RouteResult.QUEUED @@ -209,12 +226,17 @@ class MessageRouter private constructor( val resolution = ContactDirectory.resolve(conversationID) val meshTarget = resolution.meshPeerID val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (clock() - entry.lastAttemptAtMs < retryDelay(entry.sendAttempts)) continue + if (entry.sendAttempts >= OUTBOX_MAX_SEND_ATTEMPTS) continue if (meshTarget != null && isReady(mesh, meshTarget)) { mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) - iterator.remove() + entry.sendAttempts++ + entry.lastAttemptAtMs = clock() } else if (canSendViaNostr(nostrTarget)) { nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) - iterator.remove() + entry.sendAttempts++ + entry.lastAttemptAtMs = clock() + if (!canDeliverViaNostrPromptly()) attemptCourierDeposit(conversationID, entry) } } if (queued.isEmpty()) { @@ -223,6 +245,7 @@ class MessageRouter private constructor( retryState.remove(conversationID) retryState.remove(peerID) } + persistOutbox() } // Flush everything (rarely used) @@ -231,14 +254,120 @@ class MessageRouter private constructor( } @Synchronized - private fun enqueue(conversationID: String, entry: QueuedMessage) { + private fun enqueue(conversationID: String, entry: MessageOutboxStore.Entry) { val queue = outbox.getOrPut(conversationID) { mutableListOf() } + if (queue.any { it.messageID == entry.messageID }) return queue.add(entry) while (queue.size > OUTBOX_MAX_PER_PEER) { val evicted = queue.removeAt(0) Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") notifyExpired(evicted.messageID) } + while (outbox.values.sumOf { it.size } > OUTBOX_MAX_TOTAL) { + val oldest = outbox.entries + .flatMap { (id, entries) -> entries.map { id to it } } + .minByOrNull { it.second.enqueuedAtMs } ?: break + outbox[oldest.first]?.remove(oldest.second) + if (outbox[oldest.first].isNullOrEmpty()) outbox.remove(oldest.first) + notifyExpired(oldest.second.messageID) + } + persistOutbox() + } + + @Synchronized + fun onMessageAcknowledged(messageID: String, peerID: String) { + val acknowledgedConversation = ContactDirectory.canonicalConversationId(peerID) + var changed = false + outbox.entries.toList().forEach { (conversationID, queue) -> + if (ContactDirectory.canonicalConversationId(conversationID) != acknowledgedConversation) return@forEach + changed = queue.removeAll { it.messageID == messageID } || changed + if (queue.isEmpty()) { + outbox.remove(conversationID, queue) + retryState.remove(conversationID) + } + } + if (changed) persistOutbox() + } + + @Synchronized + private fun markAttempt(conversationID: String, messageID: String) { + outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts = + (outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts ?: 0) + 1 + outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.lastAttemptAtMs = clock() + persistOutbox() + } + + private fun retryDelay(attempts: Int): Long = when (attempts) { + 0 -> 0L + 1 -> 30_000L + 2 -> 2 * 60_000L + else -> 10 * 60_000L + } + + private fun attemptCourierDeposit(conversationID: String, entry: MessageOutboxStore.Entry) { + val resolution = ContactDirectory.resolve(conversationID) + val recipientKey = resolution.noiseKeyHex?.let(ContactIdentityResolver::bytesFromHex) ?: return + if (!entry.bridgeDeposited && + (entry.lastBridgeAttemptAtMs == 0L || clock() - entry.lastBridgeAttemptAtMs >= BRIDGE_RETRY_COOLDOWN_MS) + ) { + val submitted = mesh.sendBridgeCourierMessage(entry.content, entry.messageID, recipientKey) { + synchronized(this) { + val current = outbox[conversationID]?.firstOrNull { it.messageID == entry.messageID } + if (current != null) { + current.bridgeDeposited = true + persistOutbox() + } + } + } + if (submitted) { + entry.lastBridgeAttemptAtMs = clock() + persistOutbox() + } + } + if (entry.depositedCourierKeys.size >= MAX_COURIERS_PER_MESSAGE) return + val candidates = mesh.getPeerInfos() + .asSequence() + .filter { it.isConnected && it.noisePublicKey != null && !it.noisePublicKey!!.contentEquals(recipientKey) } + .filter { peer -> + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true + } catch (_: Exception) { false } + favorite || peer.hasVerifiedAnnouncement + } + .map { peer -> + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true + } catch (_: Exception) { false } + peer to favorite + } + .filter { (peer, _) -> ContactIdentityResolver.noiseKeyHex(peer.noisePublicKey!!) !in entry.depositedCourierKeys } + .sortedByDescending { (_, favorite) -> favorite } + .take(MAX_COURIERS_PER_MESSAGE - entry.depositedCourierKeys.size) + .map { (peer, _) -> peer } + .toList() + if (candidates.isEmpty()) return + val accepted = mesh.sendCourierMessage( + entry.content, + entry.messageID, + recipientKey, + candidates.map { it.id } + ).toSet() + candidates.filter { it.id in accepted }.forEach { + entry.depositedCourierKeys += ContactIdentityResolver.noiseKeyHex(it.noisePublicKey!!) + } + if (accepted.isNotEmpty()) persistOutbox() + } + + private fun canDeliverViaNostrPromptly(): Boolean = try { + nostr.canDeliverPromptly() + } catch (_: Exception) { false } + + private fun persistOutbox() { + try { outboxStore.save(outbox) } catch (e: Exception) { + Log.e(TAG, "Failed to persist sealed outbox: ${e.message}") + } } private fun notifyExpired(messageID: String) { @@ -286,6 +415,7 @@ class MessageRouter private constructor( * follow the MeshForegroundService lifecycle; getInstance restarts the scheduler * and rebinds the mesh reference when the service comes back. */ + @Synchronized fun stopOutboxScheduler() { schedulerJob?.cancel() schedulerJob = null @@ -304,6 +434,7 @@ class MessageRouter private constructor( expireOldEntries(conversationID, nowMs) val queued = outbox[conversationID] ?: return@forEach if (queued.isEmpty()) return@forEach + queued.forEach { attemptCourierDeposit(conversationID, it) } val resolution = ContactDirectory.resolve(conversationID) val meshTarget = resolution.meshPeerID @@ -338,6 +469,7 @@ class MessageRouter private constructor( outbox.remove(conversationID, queued) retryState.remove(conversationID) } + persistOutbox() } private fun canSendViaNostr(peerID: String): Boolean { @@ -385,8 +517,18 @@ class MessageRouter private constructor( } catch (_: Exception) { null } noiseHex?.let { kickHandshakeIfPending(it) - flushOutboxFor(it) + if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(pid)) { + flushOutboxFor(it) + } } + retryCourierDeposits() + } + } + + @Synchronized + private fun retryCourierDeposits() { + outbox.forEach { (conversationID, entries) -> + entries.forEach { attemptCourierDeposit(conversationID, it) } } } @@ -399,7 +541,9 @@ class MessageRouter private constructor( } catch (_: Exception) { null } noiseHex?.let { resetRetry(it) - flushOutboxFor(it) + if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(peerID)) { + flushOutboxFor(it) + } } } diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt b/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt new file mode 100644 index 00000000..788a3a3c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt @@ -0,0 +1,190 @@ +package com.bitchat.android.services + +import android.content.Context +import android.util.AtomicFile +import android.util.Base64 +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PrivateMediaPreparation +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.model.PrivateMediaMessageIdentity +import com.google.gson.Gson +import java.io.File +import java.security.MessageDigest +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** One encrypted atomic record per pending media message; no plaintext retransmission spool. */ +internal class PrivateMediaOutboxStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher("bitchat_private_media_outbox_v1") +) { + data class Entry( + val id: String, + val conversationID: String, + val recipientPeerID: String, + val encodedPacket: String, + val createdAt: Long, + val attempts: Int = 1, + val lastAttemptAt: Long = createdAt + ) + + private val directory = File(context.filesDir, "private-media-outbox") + private val gson = Gson() + + fun load(): List { + if (!directory.exists()) return emptyList() + return checkNotNull(directory.listFiles()).filter { PrivateMediaMessageIdentity.isStableID(it.name) } + .take(MAX_ENTRIES).mapNotNull { file -> + // A corrupt record stays quarantined in place; it is never retransmitted. + runCatching { + require(file.length() <= MAX_RECORD_BYTES) + val plaintext = cipher.decrypt(AtomicFile(file).readFully(), file.name.toByteArray()) + gson.fromJson(plaintext.toString(Charsets.UTF_8), Entry::class.java) + .also { require(it.id == file.name && it.attempts in 1..MAX_ATTEMPTS) } + }.getOrNull() + } + } + + fun save(entry: Entry) { + require(PrivateMediaMessageIdentity.isStableID(entry.id)) + check(directory.isDirectory || directory.mkdirs()) + val files = checkNotNull(directory.listFiles()) + val file = File(directory, entry.id) + check(file.exists() || files.size < MAX_ENTRIES) + val encrypted = cipher.encrypt(gson.toJson(entry).toByteArray(), entry.id.toByteArray()) + require(encrypted.size <= MAX_RECORD_BYTES) + check(files.sumOf { it.length() } - file.length() + encrypted.size <= MAX_TOTAL_BYTES) + val atomic = AtomicFile(file) + val stream = atomic.startWrite() + try { + stream.write(encrypted) + atomic.finishWrite(stream) + } catch (error: Exception) { + atomic.failWrite(stream) + throw error + } + } + + fun remove(id: String) { + require(PrivateMediaMessageIdentity.isStableID(id)) + AtomicFile(File(directory, id)).delete() + check(!File(directory, id).exists()) + } + + fun wipe() { + cipher.destroyKey() + check(!directory.exists() || directory.deleteRecursively()) + } + + companion object { + const val MAX_ATTEMPTS = 8 + private const val MAX_ENTRIES = 100 + private const val MAX_RECORD_BYTES = 8 * 1024 * 1024L + private const val MAX_TOTAL_BYTES = 64 * 1024 * 1024L + } +} + +/** Retries the original file and message identity until a matching recipient acknowledges it. */ +class PrivateMediaOutbox private constructor(context: Context) { + private val store = PrivateMediaOutboxStore(context) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mutex = Mutex() + private var entries: MutableMap? = null + @Volatile private var paused = false + + init { + scope.launch { + while (isActive) { + delay(15_000) + runCatching { retryPending() } + } + } + } + + private fun loaded(): MutableMap = + entries ?: store.load().associateByTo(linkedMapOf()) { it.id }.also { entries = it } + + suspend fun enqueue(id: String, conversationID: String, recipientPeerID: String, packet: BitchatFilePacket): Boolean = + withContext(Dispatchers.IO) { + mutex.withLock { + if (paused) return@withLock false + runCatching { + val encoded = checkNotNull(packet.encode()) + val entry = PrivateMediaOutboxStore.Entry(id, conversationID, recipientPeerID, + Base64.encodeToString(encoded, Base64.NO_WRAP), System.currentTimeMillis()) + store.save(entry) + loaded()[id] = entry + true + }.getOrDefault(false) + } + } + + fun acknowledge(id: String, peerID: String) { + if (!PrivateMediaMessageIdentity.isStableID(id)) return + scope.launch { + mutex.withLock { + val entry = loaded()[id] ?: return@withLock + if (entry.recipientPeerID != peerID) return@withLock + runCatching { store.remove(id) }.onSuccess { loaded().remove(id) } + } + } + } + + suspend fun wipe() { + paused = true + withContext(Dispatchers.IO) { + mutex.withLock { + store.wipe() + entries = linkedMapOf() + } + } + } + + fun resume() { paused = false } + + private suspend fun retryPending() = mutex.withLock { + if (paused) return@withLock + val mesh = com.bitchat.android.service.MeshServiceHolder.unifiedMeshService ?: return@withLock + val now = System.currentTimeMillis() + loaded().values.toList().forEach { entry -> + if (AppStateStore.privateMediaReceiptState(entry.id) == PrivateMediaReceiptState.TOMBSTONED) { + store.remove(entry.id) + loaded().remove(entry.id) + return@forEach + } + if (now - entry.createdAt > 24 * 60 * 60 * 1000L || + (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS && now - entry.lastAttemptAt >= 300_000)) { + AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Failed("No delivery receipt received")) + store.remove(entry.id) + loaded().remove(entry.id) + return@forEach + } + if (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS) return@forEach + val interval = (30_000L shl (entry.attempts - 1).coerceAtMost(4)).coerceAtMost(300_000L) + if (now - entry.lastAttemptAt < interval) return@forEach + val recipient = ContactDirectory.resolve(entry.conversationID).meshPeerID ?: return@forEach + if (recipient != entry.recipientPeerID || !mesh.supportsPrivateMediaReceipts(recipient)) return@forEach + val encoded = Base64.decode(entry.encodedPacket, Base64.NO_WRAP) + val packet = BitchatFilePacket.decode(encoded) ?: return@forEach + if (PrivateMediaMessageIdentity.stableID(mesh.myPeerID, recipient, packet.fileName) != entry.id) return@forEach + val transferID = MessageDigest.getInstance("SHA-256").digest(encoded).joinToString("") { "%02x".format(it) } + val prepared = mesh.prepareFilePrivate(recipient, packet, transferID, allowLegacyFallback = false) + if (prepared is PrivateMediaPreparation.Ready) { + val attempted = entry.copy(attempts = entry.attempts + 1, lastAttemptAt = now) + store.save(attempted) + loaded()[entry.id] = attempted + if (prepared.transfer.commit()) AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Sent) + } + } + } + + companion object { + @Volatile private var instance: PrivateMediaOutbox? = null + fun initialize(context: Context): PrivateMediaOutbox = instance ?: synchronized(this) { + instance ?: PrivateMediaOutbox(context.applicationContext).also { instance = it } + } + fun tryGetInstance(): PrivateMediaOutbox? = instance + } +} diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt index 06dcd6f3..21719bbe 100644 --- a/app/src/main/java/com/bitchat/android/services/VerificationService.kt +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -146,7 +146,10 @@ object VerificationService { val service = encryptionServiceRef?.get() ?: return null val qr = VerificationQR.fromUrlString(urlString) ?: return null val now = System.currentTimeMillis() / 1000L - if (now - qr.ts > maxAgeSeconds) return null + // Freshness in both directions: a future-dated timestamp must not + // buy a QR a longer validity window than a fresh one gets. iOS uses + // the same abs() check in VerificationService.verifyScannedQR. + if (verificationTimestampSkewSeconds(now, qr.ts) > maxAgeSeconds) return null val sig = qr.sigHex.dataFromHexString() ?: return null val signKey = qr.signKeyHex.dataFromHexString() ?: return null @@ -292,3 +295,10 @@ object VerificationService { var last: CacheEntry? = null } } + +/** Absolute age of a verification QR timestamp, in seconds. */ +internal fun verificationTimestampSkewSeconds(nowSeconds: Long, qrTimestampSeconds: Long): Long { + if (nowSeconds < 0 || qrTimestampSeconds < 0) return Long.MAX_VALUE + return if (nowSeconds >= qrTimestampSeconds) nowSeconds - qrTimestampSeconds + else qrTimestampSeconds - nowSeconds +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt new file mode 100644 index 00000000..2ae7da18 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt @@ -0,0 +1,47 @@ +package com.bitchat.android.services.bridge + + +internal class BoundedIdSet(private val capacity: Int) { + private val values = LinkedHashSet() + + fun add(id: String): Boolean { + if (!values.add(id)) return false + while (values.size > capacity) values.remove(values.first()) + return true + } + + fun contains(id: String): Boolean = id in values + + fun clear() = values.clear() +} + +/** + * Owns one logical relay subscription while assigning a distinct wire ID to + * every replacement. Relay CLOSE/REQ writes may execute out of order, but a + * delayed close can only affect the retired generation. + * + * Callers keep this object confined to their coordinator dispatcher. + */ +internal class RelaySubscriptionSlot(private val idPrefix: String) { + private var generation = 0L + private var activeId: String? = null + + fun replace(close: (String) -> Unit, open: (String) -> Unit): String { + activeId?.let(close) + val replacementId = "$idPrefix-${++generation}" + activeId = replacementId + try { + open(replacementId) + } catch (error: Throwable) { + activeId = null + throw error + } + return replacementId + } + + fun close(close: (String) -> Unit) { + val id = activeId ?: return + activeId = null + close(id) + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt new file mode 100644 index 00000000..8683241e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt @@ -0,0 +1,29 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.model.PeerCapabilities + +data class BridgedParticipant( + val pubkey: String, + val nickname: String?, + val lastSeenMs: Long +) { + val displayName: String + get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}" +} + +data class BridgeUiState( + val enabled: Boolean = false, + val nearbyOnly: Boolean = false, + val participants: List = emptyList() +) + +internal data class VerifiedBridgePeer( + val peerId: String, + val nickname: String, + val noiseKey: ByteArray, + val signingKey: ByteArray, + val isVerifiedNickname: Boolean, + val capabilities: PeerCapabilities?, + val bridgeCell: String?, + val lastSeenMs: Long +) diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt new file mode 100644 index 00000000..dcc1f668 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.protocol.BitchatPacket +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer + +/** + * Verifies bridge protocol packets against the signing key bound by the + * sender's authenticated announcement. + */ +internal object BridgePacketSignatureVerifier { + fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean { + val signature = packet.signature?.takeIf { it.size == 64 } ?: return false + val signingData = packet.toBinaryDataForSigning() ?: return false + if (publicKey.size != 32) return false + return runCatching { + Ed25519Signer().apply { + init(false, Ed25519PublicKeyParameters(publicKey, 0)) + update(signingData, 0, signingData.size) + }.verifySignature(signature) + }.getOrDefault(false) + } +} + +internal object CourierDepositAuthenticator { + fun authenticate( + packet: BitchatPacket, + fromPeerId: String, + directIngress: Boolean, + peers: List, + isTrusted: (VerifiedBridgePeer) -> Boolean + ): VerifiedBridgePeer? { + if (!directIngress || packet.senderID.toHex() != fromPeerId) return null + val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null + if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null + return peer.takeIf(isTrusted) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt new file mode 100644 index 00000000..247d6a34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt @@ -0,0 +1,46 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.MeshPacketUtils +import com.bitchat.android.mesh.BridgeMeshPort +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType + +/** + * Shared construction policy for bridge protocol packets emitted by BLE and + * Wi-Fi Aware mesh implementations. + */ +internal object BridgeProtocolPacketFactory { + fun protocolPacket( + type: MessageType, + payload: ByteArray, + senderPeerId: String, + recipientPeerId: String?, + ttl: UByte, + nowMs: Long = System.currentTimeMillis() + ): BitchatPacket? { + if (payload.isEmpty()) return null + return BitchatPacket( + version = if (payload.size > 0xFFFF) 2u else 1u, + type = type.value, + senderID = MeshPacketUtils.hexStringToByteArray(senderPeerId), + recipientID = recipientPeerId?.let(MeshPacketUtils::hexStringToByteArray), + timestamp = nowMs.toULong(), + payload = payload, + signature = null, + ttl = ttl + ) + } + + fun identityAnnouncement( + nickname: String, + noiseStaticKey: ByteArray, + signingKey: ByteArray + ): IdentityAnnouncement = + IdentityAnnouncement.forLocalPeer( + nickname, + noiseStaticKey, + signingKey, + BridgeMeshPort.advertisedCell() + ) +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt new file mode 100644 index 00000000..3c40e6b7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt @@ -0,0 +1,774 @@ +package com.bitchat.android.services.bridge + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.geohash.Geohash +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.BridgeMeshDelegate +import com.bitchat.android.mesh.BridgeOutboundPolicy +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NostrCarrierPacket +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.nostr.MeshMessageIdentity +import com.bitchat.android.nostr.NostrEvent +import com.bitchat.android.nostr.NostrFilter +import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.NostrKind +import com.bitchat.android.nostr.NostrProtocol +import com.bitchat.android.nostr.NostrRelayManager +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.ui.DataManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.util.Date +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs + +/** + * Opt-in bridge policy shared by foreground transport and Compose UI. + * + * Outbound public traffic crosses the bridge only when the author opted in + * and did not mark the message nearby-only. Passive `fromBridge` reception is + * accepted regardless of the switch because it exposes no local traffic. + */ +object MeshBridgeService : BridgeMeshDelegate { + private data class PendingUplink( + val depositor: String, + val cell: String, + val event: NostrEvent + ) + + private data class PendingDownlink( + val cell: String, + val event: NostrEvent + ) + + private const val TAG = "MeshBridgeService" + private const val PREFS = "bitchat_bridge" + private const val KEY_ENABLED = "bridge_enabled_v1" + private const val CELL_PRECISION = 6 + private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000 + private const val MAX_CONTENT_BYTES = 16_000 + private const val MAX_TRACKED_IDS = 512 + private const val MAX_PARTICIPANTS = 128 + private const val PARTICIPANT_FRESH_MS = 10L * 60 * 1000 + private const val PRESENCE_INTERVAL_MS = 4L * 60 * 1000 + private const val MAX_QUEUED_UPLINKS = 20 + private const val MAX_UPLINKS_PER_DEPOSITOR = 5 + private const val UPLINKS_PER_MINUTE_PER_DEPOSITOR = 10 + private const val MAX_PENDING_DOWNLINKS = 30 + private const val DOWNLINKS_PER_MINUTE = 20 + private const val INBOUND_PER_MINUTE = 600 + private const val INBOUND_PER_SIGNER_PER_MINUTE = 120 + private const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720 + + private val dispatcher = Dispatchers.Default.limitedParallelism(1) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val _isEnabled = MutableStateFlow(false) + val isEnabled: StateFlow = _isEnabled.asStateFlow() + private val _nearbyOnly = MutableStateFlow(false) + val nearbyOnly: StateFlow = _nearbyOnly.asStateFlow() + private val _activeCell = MutableStateFlow(null) + val activeCell: StateFlow = _activeCell.asStateFlow() + private val _bridgedParticipants = MutableStateFlow>(emptyList()) + val bridgedParticipants: StateFlow> = _bridgedParticipants.asStateFlow() + private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied) + private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous") + + @Volatile + private var appContext: Context? = null + private var relayManager: NostrRelayManager? = null + private var prekeyCoordinator: PrekeyCoordinator? = null + private var prefs: android.content.SharedPreferences? = null + @Volatile + private var meshProvider: () -> MeshService? = { null } + private var clock: () -> Long = System::currentTimeMillis + private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong + private var localLocationCell: String? = null + private var subscribedCells: Set = emptySet() + private val verifiedPeers = linkedMapOf() + private val verifiedPeerSnapshot = AtomicReference>(emptyList()) + private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val rebroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val injectedEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS) + private val queuedUplinks = mutableListOf() + private val pendingDownlinks = mutableListOf() + private val participants = linkedMapOf() + private val uplinkTimes = mutableMapOf>() + private val inboundTimes = mutableListOf() + private val inboundTimesBySigner = mutableMapOf>() + private val downlinkTimes = mutableListOf() + private val signatureAttemptTimes = mutableListOf() + private var downlinkJob: Job? = null + private var presenceJob: Job? = null + @Volatile + private var suppressPrekeyBroadcasts = false + @Volatile + private var panicQuiesced = false + + fun initialize( + context: Context, + meshProvider: () -> MeshService? = { + com.bitchat.android.service.MeshServiceHolder.unifiedMeshService + }, + clock: () -> Long = System::currentTimeMillis, + jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong + ) { + if (appContext != null) return + synchronized(this) { + if (appContext != null) return + val application = context.applicationContext + appContext = application + this.meshProvider = meshProvider + this.clock = clock + this.jitter = jitter + relayManager = NostrRelayManager.getInstance(application) + val prekeyManager = PrekeyManager.getInstance(application) + prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val storedEnabled = loadEnabledWithMigration(prefs!!) + _isEnabled.value = storedEnabled && !panicQuiesced + if (panicQuiesced && storedEnabled) { + prefs?.edit { putBoolean(KEY_ENABLED, false) } + } + outboundPolicy.set( + BridgeOutboundPolicy.capture( + enabled = _isEnabled.value, + nearbyOnly = _nearbyOnly.value + ) + ) + PeerCapabilities.setBridgeEnabled(_isEnabled.value) + prekeyCoordinator = PrekeyCoordinator( + manager = prekeyManager, + meshProvider = ::currentMesh, + peersProvider = verifiedPeerSnapshot::get, + clock = clock + ) + + } + + val location = LocationChannelManager.getInstance(context) + scope.launch { + location.availableChannels.collect { channels -> + if (!_isEnabled.value) { + localLocationCell = null + return@collect + } + localLocationCell = channels + .firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD } + ?.geohash + ?.take(CELL_PRECISION) + refreshRendezvous() + } + } + scope.launch { + relayManager?.isConnected?.collect { connected -> + if (connected) { + refreshRendezvous(forceSubscriptions = true) + flushQueuedUplinks() + publishPresence() + } + } + } + scope.launch { + if (_isEnabled.value) { + relayManager?.connect() + location.refreshChannels( + forceFresh = true, + updatePlaceNames = false + ) + refreshRendezvous(forceSubscriptions = true) + } + if (_isEnabled.value) startPresenceLoop() + delay(2_000) + broadcastPrekeys(force = true) + } + } + + private val publicationGeneration = java.util.concurrent.atomic.AtomicLong() + fun publicationPermit(): () -> Boolean { + val generation = publicationGeneration.get() + val enabledAtCapture = _isEnabled.value + return { enabledAtCapture && _isEnabled.value && generation == publicationGeneration.get() } + } + + fun setEnabled(enabled: Boolean) { + if (outboundPolicy.get().enabled == enabled) return + if (enabled) { + panicQuiesced = false + suppressPrekeyBroadcasts = false + } + publicationGeneration.incrementAndGet() + outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false)) + _isEnabled.value = enabled + prefs?.edit { putBoolean(KEY_ENABLED, enabled) } + PeerCapabilities.setBridgeEnabled(enabled) + _nearbyOnly.value = false + scope.launch { + if (!enabled) { + stopPresenceLoop() + closeSubscriptions() + queuedUplinks.clear() + pendingDownlinks.clear() + participants.clear() + publishParticipants() + localLocationCell = null + _activeCell.value = null + } else { + startPresenceLoop() + relayManager?.connect() + LocationChannelManager.getInstance(requireContext()) + .refreshChannels( + forceFresh = true, + updatePlaceNames = false + ) + refreshRendezvous(forceSubscriptions = true) + broadcastPrekeys(force = true) + } + currentMesh()?.sendBroadcastAnnounce() + } + } + + fun setNearbyOnly(enabled: Boolean) { + outboundPolicy.updateAndGet { current -> + BridgeOutboundPolicy.capture(current.enabled, nearbyOnly = enabled) + } + _nearbyOnly.value = enabled + } + + /** Cell included in announce TLV 0x06 while the bridge switch is on. */ + override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value } + + override fun outboundPolicy(): BridgeOutboundPolicy = outboundPolicy.get() + + override fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String?, + policyAtSend: BridgeOutboundPolicy + ) { + scope.launch { + if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch + val cell = _activeCell.value ?: currentCell() ?: return@launch + if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch + val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext()) + val event = NostrProtocol.createBridgeMeshEvent( + content = content, + cell = cell, + senderIdentity = identity, + nickname = nickname, + meshSenderId = senderPeerId, + meshTimestampMs = timestampMs + ) + if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch + publishedEventIds.add(event.id) + injectedEventIds.add(event.id) + if (relayManager?.isConnected?.value == true) { + relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit()) + } else { + val peer = availableBridgePeer() ?: return@launch + NostrCarrierPacket.fromEvent( + NostrCarrierPacket.Direction.TO_BRIDGE, + cell, + event + )?.encode()?.let { currentMesh()?.sendNostrCarrier(it, peer.peerId) } + } + } + } + + /** Called only after a public radio packet's Ed25519 signature was accepted. */ + override fun handleAuthenticatedRadioMessage(messageId: String) { + if (panicQuiesced || messageId.isBlank()) return + scope.launch { + radioMessageIds.add(messageId) + pendingDownlinks.removeAll { pending -> + classifyMessage(pending.event, pending.cell)?.bridgeRadioMessageIdHint == messageId + } + } + } + + override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) { + if (panicQuiesced) return + scope.launch { + val peer = VerifiedBridgePeer( + peerId = peerId, + nickname = announcement.nickname, + noiseKey = announcement.noisePublicKey.copyOf(), + signingKey = announcement.signingPublicKey.copyOf(), + isVerifiedNickname = + currentMesh()?.getPeerInfo(peerId)?.isVerifiedNickname == true, + capabilities = announcement.capabilities, + bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash), + lastSeenMs = clock() + ) + verifiedPeers[peerId] = peer + while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first()) + publishVerifiedPeerSnapshot() + prekeyCoordinator?.handlePeerVerified(peerId) + if (_isEnabled.value) { + refreshRendezvous() + } + broadcastPrekeys() + } + } + + override fun handlePrekeyPacket(packet: BitchatPacket) { + if (panicQuiesced) return + scope.launch { prekeyCoordinator?.handlePacket(packet) } + } + + override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { + if (panicQuiesced) return + scope.launch { + val carrier = NostrCarrierPacket.decode(payload) ?: return@launch + when (carrier.direction) { + NostrCarrierPacket.Direction.TO_BRIDGE -> { + if (directedToUs) handleUplink(carrier, fromPeerId) + } + NostrCarrierPacket.Direction.FROM_BRIDGE -> { + if (!directedToUs) handleDownlink(carrier) + } + NostrCarrierPacket.Direction.TO_GATEWAY, + NostrCarrierPacket.Direction.FROM_GATEWAY -> MeshGatewayService.handleCarrier(carrier, fromPeerId, directedToUs) + } + } + } + + @SuppressLint("ApplySharedPref", "UseKtx") + suspend fun wipe() { + // Revoke policy synchronously so no already-queued bridge work can be + // authorized by the pre-panic setting. + publicationGeneration.incrementAndGet() + outboundPolicy.set(BridgeOutboundPolicy.Denied) + panicQuiesced = true + suppressPrekeyBroadcasts = true + _isEnabled.value = false + _nearbyOnly.value = false + PeerCapabilities.setBridgeEnabled(false) + kotlinx.coroutines.withContext(dispatcher) { + prefs?.edit()?.putBoolean(KEY_ENABLED, false)?.commit() + stopPresenceLoop() + downlinkJob?.cancel() + downlinkJob = null + closeSubscriptions() + queuedUplinks.clear() + pendingDownlinks.clear() + localLocationCell = null + _activeCell.value = null + verifiedPeers.clear() + publishVerifiedPeerSnapshot() + participants.clear() + publishedEventIds.clear() + receivedEventIds.clear() + meshBroadcastEventIds.clear() + rebroadcastEventIds.clear() + injectedEventIds.clear() + radioMessageIds.clear() + uplinkTimes.clear() + inboundTimes.clear() + inboundTimesBySigner.clear() + downlinkTimes.clear() + signatureAttemptTimes.clear() + prekeyCoordinator?.wipe() + publishParticipants() + } + } + + private fun publishVerifiedPeerSnapshot() { + verifiedPeerSnapshot.set( + verifiedPeers.values.map { peer -> + peer.copy( + noiseKey = peer.noiseKey.copyOf(), + signingKey = peer.signingKey.copyOf() + ) + } + ) + } + + private suspend fun refreshRendezvous(forceSubscriptions: Boolean = false) { + if (!_isEnabled.value) return + val cell = currentCell() + val changed = cell != _activeCell.value + if (changed) { + _activeCell.value = cell + currentMesh()?.sendBroadcastAnnounce() + } + if (cell == null) return + val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) } + if (changed || forceSubscriptions || cells != subscribedCells) { + val targets = linkedSetOf() + cells.forEach { subscribedCell -> + relayManager?.ensureGeohashRelaysConnected(subscribedCell) + targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty() + } + relayManager?.let { manager -> + rendezvousSubscription.replace( + close = manager::unsubscribe, + open = { subscriptionId -> + manager.subscribe( + filter = NostrFilter.bridgeRendezvous( + cells, + since = clock() - MAX_EVENT_AGE_MS + ), + id = subscriptionId, + handler = { event -> scope.launch { handleRendezvousEvent(event) } }, + targetRelayUrls = targets.toList() + ) + } + ) + subscribedCells = cells + } + publishPresence() + } + } + + private fun currentCell(): String? { + localLocationCell?.takeIf(::isValidGeohash)?.let { return it.take(CELL_PRECISION) } + return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION) + } + + private fun availableBridgePeer(): VerifiedBridgePeer? = + verifiedPeers.values.firstOrNull { peer -> + peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true && + peer.bridgeCell != null && + currentMesh()?.getPeerInfo(peer.peerId)?.isConnected == true + } + + private fun handleRendezvousEvent(event: NostrEvent) { + if (!_isEnabled.value) return + val cell = event.tagValue("r") ?: return + if (cell !in subscribedCells || publishedEventIds.contains(event.id)) return + if (isOwnEvent(event, cell)) { + publishedEventIds.add(event.id) + return + } + if (!allowSignatureAttempt() || !event.isValidSignature()) return + if (!receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return + if (!isFresh(event) || event.tagValue("r") != cell || !isValidGeohash(cell)) return + + when (event.kind) { + NostrKind.GEOHASH_PRESENCE -> recordParticipant(event.pubkey, null) + NostrKind.EPHEMERAL_EVENT -> { + val message = classifyMessage(event, cell) ?: return + val localRadio = message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true + if (!localRadio && injectBridgeMessage(message)) { + recordParticipant(event.pubkey, event.tagValue("n")) + } + if (!localRadio && + !meshBroadcastEventIds.contains(event.id) && + !rebroadcastEventIds.contains(event.id) && + pendingDownlinks.none { it.event.id == event.id } + ) { + pendingDownlinks += PendingDownlink(cell, event) + while (pendingDownlinks.size > MAX_PENDING_DOWNLINKS) pendingDownlinks.removeAt(0) + scheduleDownlink(jitter = true) + } + } + } + } + + private fun handleUplink(carrier: NostrCarrierPacket, depositor: String) { + if (!_isEnabled.value || !allowUplink(depositor)) return + val event = structurallyValidEvent(carrier) ?: return + if (meshBroadcastEventIds.contains(event.id) || + publishedEventIds.contains(event.id) || + queuedUplinks.any { it.event.id == event.id } + ) { + return + } + if (!allowSignatureAttempt() || !event.isValidSignature()) return + if (relayManager?.isConnected?.value == true) { + publishCarriedEvent(event, carrier.geohash) + } else { + if (queuedUplinks.count { it.depositor == depositor } >= MAX_UPLINKS_PER_DEPOSITOR) return + queuedUplinks += PendingUplink(depositor, carrier.geohash, event) + while (queuedUplinks.size > MAX_QUEUED_UPLINKS) queuedUplinks.removeAt(0) + } + } + + private fun handleDownlink(carrier: NostrCarrierPacket) { + val event = structurallyValidEvent(carrier) ?: return + if (publishedEventIds.contains(event.id) || isOwnEvent(event, carrier.geohash)) return + if (!allowSignatureAttempt() || !event.isValidSignature()) return + val firstMesh = meshBroadcastEventIds.add(event.id) + if (!firstMesh || !receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return + val message = classifyMessage(event, carrier.geohash) ?: return + if (injectBridgeMessage(message)) recordParticipant(event.pubkey, event.tagValue("n")) + } + + private fun structurallyValidEvent(carrier: NostrCarrierPacket): NostrEvent? { + if (!isValidGeohash(carrier.geohash) || + carrier.eventJson.size > NostrCarrierPacket.MAX_EVENT_JSON_BYTES + ) { + return null + } + val event = carrier.event() ?: return null + if (!isFresh(event) || event.tagValue("r") != carrier.geohash) return null + return when (event.kind) { + NostrKind.GEOHASH_PRESENCE -> event + NostrKind.EPHEMERAL_EVENT -> + event.takeIf { classifyMessage(it, carrier.geohash) != null } + else -> null + } + } + + private fun classifyMessage(event: NostrEvent, cell: String): BitchatMessage? { + if (event.kind != NostrKind.EPHEMERAL_EVENT || + !isFresh(event) || + event.tagValue("r") != cell || + !isValidGeohash(cell) + ) { + return null + } + val content = event.content + if (content.isBlank() || content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return null + val nickname = event.tagValue("n")?.trim()?.takeIf { it.isNotEmpty() } + val m = event.tags.firstOrNull { it.size >= 4 && it[0] == "m" } + val radioHint = if (m != null && + m[2].matches(Regex("^[0-9a-fA-F]{16}$")) + ) { + m[3].toLongOrNull()?.let { MeshMessageIdentity.stableId(m[2], it, content) } + } else { + null + } + return BitchatMessage( + id = event.id, + sender = "${nickname ?: "anon"}#${event.pubkey.takeLast(4)}", + content = content, + timestamp = Date(event.createdAt * 1000L), + senderPeerID = "bridge:${event.pubkey.take(16)}", + isBridged = true, + bridgeRadioMessageIdHint = radioHint + ) + } + + private fun injectBridgeMessage(message: BitchatMessage): Boolean { + if (!injectedEventIds.add(message.id)) return false + if (message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) return false + AppStateStore.addPublicMessage(message) + return true + } + + private fun radioCopyPresent(messageId: String): Boolean = + radioMessageIds.contains(messageId) || AppStateStore.hasRadioPublicMessage(messageId) + + private fun scheduleDownlink(jitter: Boolean) { + if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return + val now = clock() + downlinkTimes.removeAll { now - it >= 60_000 } + val waitMs = if (jitter) { + jitter(200, 1_501) + } else { + (downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50) + } + downlinkJob = scope.launch { + delay(waitMs) + drainDownlinks() + } + } + + private fun drainDownlinks() { + val now = clock() + downlinkTimes.removeAll { now - it >= 60_000 } + while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) { + val item = pendingDownlinks.removeAt(0) + if (!isFresh(item.event) || + meshBroadcastEventIds.contains(item.event.id) || + rebroadcastEventIds.contains(item.event.id) + ) { + continue + } + val message = classifyMessage(item.event, item.cell) + if (message?.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) continue + val payload = NostrCarrierPacket.fromEvent( + NostrCarrierPacket.Direction.FROM_BRIDGE, + item.cell, + item.event + )?.encode() ?: continue + currentMesh()?.sendNostrCarrier(payload) + rebroadcastEventIds.add(item.event.id) + downlinkTimes += clock() + } + if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false) + } + + private fun flushQueuedUplinks() { + if (!_isEnabled.value || relayManager?.isConnected?.value != true) return + val queued = queuedUplinks.toList() + queuedUplinks.clear() + queued.filterNot { publishedEventIds.contains(it.event.id) } + .forEach { publishCarriedEvent(it.event, it.cell) } + } + + private fun publishCarriedEvent(event: NostrEvent, cell: String) { + publishedEventIds.add(event.id) + relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit()) + } + + private fun publishPresence() { + if (!_isEnabled.value || relayManager?.isConnected?.value != true) return + val cell = _activeCell.value ?: return + val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext()) + val event = NostrProtocol.createBridgePresenceEvent(cell, identity) + publishedEventIds.add(event.id) + relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit()) + } + + private fun startPresenceLoop() { + if (presenceJob?.isActive == true) return + presenceJob = scope.launch { + while (true) { + delay(PRESENCE_INTERVAL_MS) + pruneParticipants() + if (_isEnabled.value) { + LocationChannelManager.getInstance(requireContext()) + .refreshChannels( + forceFresh = true, + updatePlaceNames = false + ) + refreshRendezvous() + publishPresence() + broadcastPrekeys() + } + } + } + } + + private fun stopPresenceLoop() { + presenceJob?.cancel() + presenceJob = null + } + + fun resumeAfterPanic() { + panicQuiesced = false + suppressPrekeyBroadcasts = false + refreshPrekeys() + } + + fun refreshPrekeys() { + scope.launch { broadcastPrekeys(force = true) } + } + + private fun broadcastPrekeys(force: Boolean = false) { + if (suppressPrekeyBroadcasts) return + prekeyCoordinator?.broadcast(force) + } + + private fun recordParticipant(pubkey: String, nickname: String?) { + val now = clock() + participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS } + if (pubkey !in participants && participants.size >= MAX_PARTICIPANTS) { + participants.minByOrNull { it.value.lastSeenMs }?.key?.let(participants::remove) + } + val previous = participants[pubkey] + participants[pubkey] = BridgedParticipant( + pubkey, + nickname?.trim()?.takeIf { it.isNotEmpty() } ?: previous?.nickname, + now + ) + publishParticipants() + } + + private fun pruneParticipants() { + val now = clock() + participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS } + publishParticipants() + } + + private fun publishParticipants() { + _bridgedParticipants.value = participants.values.sortedByDescending { it.lastSeenMs } + } + + private fun allowUplink(depositor: String): Boolean { + val now = clock() + val times = uplinkTimes.getOrPut(depositor) { mutableListOf() } + times.removeAll { now - it >= 60_000 } + if (times.size >= UPLINKS_PER_MINUTE_PER_DEPOSITOR) return false + times += now + return true + } + + private fun allowInbound(signer: String): Boolean { + val now = clock() + inboundTimes.removeAll { now - it >= 60_000 } + if (inboundTimes.size >= INBOUND_PER_MINUTE) return false + val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() } + signerTimes.removeAll { now - it >= 60_000 } + if (signerTimes.size >= INBOUND_PER_SIGNER_PER_MINUTE) return false + inboundTimes += now + signerTimes += now + return true + } + + private fun allowSignatureAttempt(): Boolean { + val now = clock() + signatureAttemptTimes.removeAll { now - it >= 60_000 } + if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false + signatureAttemptTimes += now + return true + } + + private fun closeSubscriptions() { + relayManager?.let { manager -> + rendezvousSubscription.close(manager::unsubscribe) + } + subscribedCells = emptySet() + } + + private fun isOwnEvent(event: NostrEvent, cell: String): Boolean = + runCatching { + NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext()) + .publicKeyHex.equals(event.pubkey, ignoreCase = true) + }.getOrDefault(false) + + private fun isFresh(event: NostrEvent): Boolean = + abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS + + private fun isValidGeohash(value: String): Boolean = + value.length in 1..12 && + value.matches(Regex("^[0123456789bcdefghjkmnpqrstuvwxyz]+$", RegexOption.IGNORE_CASE)) + + private fun NostrEvent.tagValue(name: String): String? = + tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1) + + private fun currentMesh(): MeshService? = meshProvider() + + private fun requireContext(): Context = + checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" } + + private fun loadEnabledWithMigration( + preferences: android.content.SharedPreferences + ): Boolean { + if (preferences.contains(KEY_ENABLED)) return preferences.getBoolean(KEY_ENABLED, false) + val legacyKeys = listOf("gateway_user_enabled", "gateway_enabled", "gateway.userEnabled") + val migrated = legacyKeys.any { preferences.getBoolean(it, false) } + preferences.edit { + putBoolean(KEY_ENABLED, migrated) + legacyKeys.forEach(::remove) + } + return migrated + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshGatewayService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshGatewayService.kt new file mode 100644 index 00000000..073b42b4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshGatewayService.kt @@ -0,0 +1,167 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.model.NostrCarrierPacket +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.nostr.* +import com.bitchat.android.service.MeshServiceHolder +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +internal object GatewayEventPolicy { + fun inspect(carrier: NostrCarrierPacket, nowSeconds: Long): NostrEvent? { + if (!Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}").matches(carrier.geohash)) return null + val event = carrier.event() ?: return null + if (event.kind != NostrKind.EPHEMERAL_EVENT || + event.tags.none { it.size >= 2 && it[0] == "g" && it[1] == carrier.geohash } || + nowSeconds - event.createdAt.toLong() !in -900L..900L) return null + return event + } +} + +/** Opt-in internet sharing for signed public geohash events (carrier directions 1 and 2). */ +object MeshGatewayService { + private val dispatcher = Dispatchers.IO.limitedParallelism(1) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val generation = AtomicLong() + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow = _enabled.asStateFlow() + private var context: Context? = null + private val radioEvents = BoundedIdSet(512) + private val published = BoundedIdSet(512) + private val delivered = BoundedIdSet(512) + private val inboundTimes = ArrayDeque() + private val perPeer = linkedMapOf>() + private val downlinkTimes = ArrayDeque() + private val pending = linkedMapOf>() + private var drain: Job? = null + private val mesh get() = MeshServiceHolder.unifiedMeshService + private val relays get() = context?.let(NostrRelayManager::getInstance) + + @Synchronized + fun initialize(application: Context) { + if (context != null) return + context = application.applicationContext + _enabled.value = application.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE).getBoolean("enabled", false) + PeerCapabilities.setGatewayEnabled(_enabled.value) + } + + fun setEnabled(enabled: Boolean) { + generation.incrementAndGet() + _enabled.value = enabled + context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.putBoolean("enabled", enabled)?.apply() + PeerCapabilities.setGatewayEnabled(enabled) + if (!enabled) scope.launch { pending.clear(); drain?.cancel(); drain = null } + mesh?.sendBroadcastAnnounce() + } + + suspend fun wipe() { + setEnabled(false) + withContext(dispatcher) { + pending.clear() + drain?.cancel() + drain = null + radioEvents.clear() + published.clear() + delivered.clear() + inboundTimes.clear() + perPeer.clear() + downlinkTimes.clear() + check(context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.clear()?.commit() != false) + } + } + + private fun permit(): () -> Boolean { + val captured = generation.get() + val enabledAtSend = _enabled.value + return { enabledAtSend && _enabled.value && generation.get() == captured } + } + + fun uplinkIfOffline(event: NostrEvent, cell: String, liveToken: Long?) { + val current = mesh ?: return + if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return + if (relays?.hasConnectedRelay(relays?.getRelaysForGeohash(cell).orEmpty()) == true) return + val gateway = current.getPeerNicknames().keys.firstOrNull { peer -> + current.getPeerInfo(peer)?.let { it.isConnected && it.hasVerifiedAnnouncement && + it.capabilities?.contains(PeerCapabilities.GATEWAY) == true } == true + } ?: return + val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, cell, event) ?: return + scope.launch { + if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return@launch + radioEvents.add(event.id) + current.sendNostrCarrier(carrier.encode(), gateway) + } + } + + fun handleCarrier(carrier: NostrCarrierPacket, peerID: String, directedToUs: Boolean) { + val application = context ?: return + val allowed = permit() + scope.launch { + val event = GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) ?: return@launch + if (carrier.direction == NostrCarrierPacket.Direction.TO_GATEWAY) { + if (!directedToUs || !allowed() || published.contains(event.id)) return@launch + if (mesh?.getPeerInfo(peerID)?.hasVerifiedAnnouncement != true || !allowInbound(peerID)) return@launch + if (!event.isValidSignature() || !allowed()) return@launch + radioEvents.add(event.id) + published.add(event.id) + relays?.sendEventToGeohash(event, carrier.geohash, publicationAllowed = allowed) + NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash) + } else if (carrier.direction == NostrCarrierPacket.Direction.FROM_GATEWAY) { + if (directedToUs || delivered.contains(event.id) || !allowInbound(peerID)) return@launch + val channelManager = LocationChannelManager.getInstance(application) + val selected = (channelManager.selectedChannel.value as? ChannelID.Location)?.channel ?: return@launch + if (selected.geohash != carrier.geohash || !channelManager.canUseSelectedLocationChannel(selected)) return@launch + if (!event.isValidSignature()) return@launch + radioEvents.add(event.id) + delivered.add(event.id) + NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash) + } + } + } + + fun rebroadcastRelayEvent(event: NostrEvent, cell: String, liveToken: Long?) { + val allowed = permit() + if (!allowed()) return + val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.FROM_GATEWAY, cell, event) ?: return + scope.launch { + if (!allowed() || (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) || + GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) == null || + radioEvents.contains(event.id) || delivered.contains(event.id) || pending.containsKey(event.id)) return@launch + if (!event.isValidSignature() || pending.size >= 100) return@launch + pending[event.id] = carrier to liveToken + if (drain?.isActive != true) drain = scope.launch { + while (pending.isNotEmpty() && allowed()) { + delay(kotlin.random.Random.nextLong(200, 1501)) + val now = System.currentTimeMillis() + while (downlinkTimes.firstOrNull()?.let { now - it >= 60_000 } == true) downlinkTimes.removeFirst() + if (downlinkTimes.size >= 30) { delay(1000); continue } + val next = pending.entries.first() + pending.remove(next.key) + val (outgoing, token) = next.value + if (!allowed() || (token != null && !LiveLocationPrivacyGate.accepts(token)) || radioEvents.contains(next.key) || + GatewayEventPolicy.inspect(outgoing, now / 1000) == null) continue + mesh?.sendNostrCarrier(outgoing.encode()) + delivered.add(next.key) + downlinkTimes.addLast(now) + } + } + } + } + + private fun allowInbound(peerID: String): Boolean { + val now = System.currentTimeMillis() + while (inboundTimes.firstOrNull()?.let { now - it >= 60_000 } == true) inboundTimes.removeFirst() + perPeer.entries.removeAll { it.value.lastOrNull()?.let { now - it >= 60_000 } != false } + if (perPeer.size >= 200 && peerID !in perPeer) return false + val times = perPeer.getOrPut(peerID) { ArrayDeque() } + while (times.firstOrNull()?.let { now - it >= 60_000 } == true) times.removeFirst() + if (inboundTimes.size >= 20 || times.size >= 5) return false + inboundTimes.addLast(now) + times.addLast(now) + return true + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt new file mode 100644 index 00000000..38eaf147 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.ContactIdentityResolver + +/** + * Coordinates authenticated prekey packets without owning cryptographic + * persistence. [PrekeyManager] remains the repository/crypto boundary. + */ +internal class PrekeyCoordinator( + private val manager: PrekeyManager, + private val meshProvider: () -> MeshService?, + private val peersProvider: () -> List, + private val clock: () -> Long = System::currentTimeMillis +) { + private val pendingPackets = linkedMapOf() + private var lastBroadcastMs = 0L + + fun handlePacket(packet: BitchatPacket) { + val bundle = PrekeyBundle.decode(packet.payload) ?: return + val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey) + if (owner != packet.senderID.toHex()) return + val peer = peersProvider().firstOrNull { it.peerId == owner } + if (peer == null || !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)) { + if (pendingPackets.size < MAX_PENDING_PACKETS || owner in pendingPackets) { + pendingPackets[owner] = packet + } + return + } + ingestVerified(packet, bundle, peer) + } + + fun handlePeerVerified(peerId: String) { + pendingPackets.remove(peerId)?.let(::handlePacket) + } + + fun broadcast(force: Boolean = false) { + val now = clock() + if (!force && now - lastBroadcastMs < REBROADCAST_INTERVAL_MS) return + val bundle = manager.currentSignedBundle(now) ?: return + val encoded = bundle.encode() ?: return + lastBroadcastMs = now + meshProvider()?.sendPrekeyBundle(encoded) + } + + fun wipe() { + pendingPackets.clear() + lastBroadcastMs = 0L + manager.wipe() + } + + private fun ingestVerified( + packet: BitchatPacket, + bundle: PrekeyBundle, + peer: VerifiedBridgePeer + ) { + if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return + if (manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())) { + com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager?.onPublicPacketSeen(packet) + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private companion object { + const val MAX_PENDING_PACKETS = 64 + const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000 + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt new file mode 100644 index 00000000..39c5ef97 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt @@ -0,0 +1,331 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import android.util.Base64 +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.noise.CourierNoiseCrypto +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.SecureRandom + +/** + * Owns local one-time prekeys and verified peer bundles for courier v2. + * + * Local private keys use EncryptedSharedPreferences through + * [SecureIdentityStateManager]. Peer bundles contain public material only, + * but their consumption assignments are persisted so retries of one message + * never spend additional prekeys. + */ +class PrekeyManager internal constructor( + private val identity: PrekeyIdentity, + private val localStore: LocalPrekeyStore, + private val peerStore: PeerPrekeyStore, + private val randomBytes: () -> ByteArray +) { + data class Sealed( + val ciphertext: ByteArray, + val prekeyId: Long? + ) + + data class Opened( + val payload: ByteArray, + val senderStaticKey: ByteArray, + val consumedPrekey: Boolean + ) + + private val lock = Any() + private var local: LocalPrekeyState? = null + private var peerBundles: MutableMap? = null + + fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) { + val staticKey = identity.staticKey()?.second ?: return@synchronized null + val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null + val state = loadLocalLocked() + replenishLocked(state, nowMs) + val prekeys = state.records + .asSequence() + .filter { it.consumedAt == null } + .sortedBy { it.id } + .mapNotNull { record -> + decode(record.privateKey)?.let { privateKey -> + PrekeyBundle.Prekey(record.id, CourierNoiseCrypto.publicKey(privateKey)) + } + } + .toList() + if (prekeys.isEmpty()) return@synchronized null + + val unsigned = PrekeyBundle( + noiseStaticPublicKey = staticKey, + prekeys = prekeys, + generatedAt = state.generatedAt, + signature = ByteArray(PrekeyBundle.SIGNATURE_LENGTH) + ) + val signature = signEd25519(unsigned.signableBytes(), signingPrivateKey) ?: return@synchronized null + unsigned.copy(signature = signature) + } + + fun verifyAndIngest( + bundle: PrekeyBundle, + expectedNoiseKey: ByteArray, + announceBoundSigningKey: ByteArray, + nowMs: Long = System.currentTimeMillis() + ): Boolean { + if (!bundle.noiseStaticPublicKey.contentEquals(expectedNoiseKey) || + announceBoundSigningKey.size != PrekeyBundle.KEY_LENGTH || + !verifyEd25519(bundle.signature, bundle.signableBytes(), announceBoundSigningKey) + ) { + return false + } + + synchronized(lock) { + val bundles = loadPeerBundlesLocked() + val key = encode(bundle.noiseStaticPublicKey) + val existing = bundles[key] + if (existing != null && existing.generatedAt >= bundle.generatedAt) return false + + val freshIds = bundle.prekeys.map { it.id }.toSet() + bundles[key] = StoredPeerPrekeyBundle( + noiseKey = key, + generatedAt = bundle.generatedAt, + prekeyIds = bundle.prekeys.map { it.id }, + prekeyPublicKeys = bundle.prekeys.map { encode(it.publicKey) }, + usedIds = existing?.usedIds?.filterTo(mutableSetOf()) { it in freshIds } ?: mutableSetOf(), + assignments = existing?.assignments + ?.filterValues { it in freshIds } + ?.toMutableMap() ?: mutableMapOf(), + updatedAt = nowMs + ) + while (bundles.size > MAX_PEERS) { + bundles.minByOrNull { it.value.updatedAt }?.key?.let(bundles::remove) + } + persistPeerBundlesLocked(bundles) + return true + } + } + + fun seal( + payload: ByteArray, + messageId: String, + recipientNoiseKey: ByteArray, + recipientAdvertisesPrekeys: Boolean, + nowMs: Long = System.currentTimeMillis() + ): Sealed { + val senderPrivateKey = identity.staticKey()?.first + ?: throw IllegalStateException("Noise static identity is unavailable") + val assigned = if (recipientAdvertisesPrekeys) { + assignPrekey(messageId, recipientNoiseKey, nowMs) + } else { + null + } + return if (assigned != null) { + Sealed( + CourierNoiseCrypto.sealToPrekey(payload, senderPrivateKey, assigned), + assigned.id + ) + } else { + Sealed( + CourierNoiseCrypto.seal(payload, senderPrivateKey, recipientNoiseKey), + null + ) + } + } + + fun open( + ciphertext: ByteArray, + prekeyId: Long?, + nowMs: Long = System.currentTimeMillis() + ): Opened { + if (prekeyId == null) { + val staticPrivateKey = identity.staticKey()?.first + ?: throw IllegalStateException("Noise static identity is unavailable") + val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey) + return Opened(opened.payload, opened.senderStaticKey, false) + } + + synchronized(lock) { + val state = loadLocalLocked() + pruneLocked(state, nowMs) + val record = state.records.firstOrNull { it.id == prekeyId } + ?: throw IllegalArgumentException("Unknown or expired courier prekey") + val consumedAt = record.consumedAt + if (consumedAt != null && nowMs - consumedAt > CONSUMED_GRACE_MS) { + throw IllegalArgumentException("Courier prekey grace window expired") + } + val privateKey = decode(record.privateKey) + ?: throw IllegalArgumentException("Invalid courier prekey") + val opened = CourierNoiseCrypto.openWithPrekey(ciphertext, privateKey, prekeyId) + val newlyConsumed = record.consumedAt == null + if (newlyConsumed) { + record.consumedAt = nowMs + advanceGeneratedAtLocked(state, nowMs) + replenishLocked(state, nowMs) + persistLocalLocked(state) + } + return Opened(opened.payload, opened.senderStaticKey, newlyConsumed) + } + } + + fun hasUsableBundle( + recipientNoiseKey: ByteArray, + nowMs: Long = System.currentTimeMillis() + ): Boolean = synchronized(lock) { + val bundle = loadPeerBundlesLocked()[encode(recipientNoiseKey)] ?: return@synchronized false + isFresh(bundle, nowMs) && bundle.prekeyIds.any { it !in bundle.usedIds } + } + + fun wipe() = synchronized(lock) { + local = LocalPrekeyState() + peerBundles = mutableMapOf() + localStore.clear() + peerStore.clear() + } + + private fun assignPrekey( + messageId: String, + recipientNoiseKey: ByteArray, + nowMs: Long + ): PrekeyBundle.Prekey? = synchronized(lock) { + val bundles = loadPeerBundlesLocked() + val key = encode(recipientNoiseKey) + val bundle = bundles[key] ?: return@synchronized null + if (!isFresh(bundle, nowMs)) return@synchronized null + + bundle.assignments[messageId]?.let { assigned -> + val index = bundle.prekeyIds.indexOf(assigned) + if (index >= 0) { + return@synchronized decode(bundle.prekeyPublicKeys[index]) + ?.let { PrekeyBundle.Prekey(assigned, it) } + } + } + + val index = bundle.prekeyIds.indices + .filter { bundle.prekeyIds[it] !in bundle.usedIds } + .minByOrNull { bundle.prekeyIds[it] } + ?: return@synchronized null + val id = bundle.prekeyIds[index] + val publicKey = decode(bundle.prekeyPublicKeys[index]) ?: return@synchronized null + bundle.usedIds += id + bundle.assignments[messageId] = id + bundle.updatedAt = nowMs + persistPeerBundlesLocked(bundles) + PrekeyBundle.Prekey(id, publicKey) + } + + private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean { + val beforeRecords = state.records.size + val beforeUnconsumed = state.records.count { it.consumedAt == null } + pruneLocked(state, nowMs) + val unconsumed = state.records.count { it.consumedAt == null } + var changed = unconsumed != beforeUnconsumed + if (unconsumed < REPLENISH_THRESHOLD) { + repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) { + val privateKey = randomBytes() + require(privateKey.size == PrekeyBundle.KEY_LENGTH) + state.records += LocalPrekeyRecord( + id = state.nextId and 0xFFFF_FFFFL, + privateKey = encode(privateKey), + createdAt = nowMs + ) + state.nextId = (state.nextId + 1) and 0xFFFF_FFFFL + } + advanceGeneratedAtLocked(state, nowMs) + changed = true + } + if (changed || state.records.size != beforeRecords) persistLocalLocked(state) + return changed + } + + private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) { + state.records.removeAll { record -> + record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS } + ?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS) + } + } + + private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) { + state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1) + } + + private fun loadLocalLocked(): LocalPrekeyState { + local?.let { return it } + val loaded = localStore.load() + local = loaded + return loaded + } + + private fun persistLocalLocked(state: LocalPrekeyState) { + try { localStore.save(state) } catch (error: Exception) { + local = null + throw error + } + } + + private fun loadPeerBundlesLocked(): MutableMap { + peerBundles?.let { return it } + return peerStore.load().also { peerBundles = it } + } + + private fun persistPeerBundlesLocked(bundles: Map) { + try { peerStore.save(bundles) } catch (error: Exception) { + peerBundles = null + throw error + } + } + + private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean = + nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS + + private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching { + Ed25519Signer().apply { + init(true, Ed25519PrivateKeyParameters(privateKey, 0)) + update(data, 0, data.size) + }.generateSignature() + }.getOrNull() + + private fun verifyEd25519(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = + runCatching { + Ed25519Signer().apply { + init(false, Ed25519PublicKeyParameters(publicKey, 0)) + update(data, 0, data.size) + }.verifySignature(signature) + }.getOrDefault(false) + + private fun encode(data: ByteArray): String = + Base64.encodeToString(data, Base64.NO_WRAP) + + private fun decode(value: String): ByteArray? = + runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull() + + companion object { + private const val PEER_PREFS = "bitchat_prekey_bundles" + private const val REPLENISH_THRESHOLD = 3 + private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000 + private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000 + private const val MAX_BUNDLE_AGE_MS = 7L * 24 * 60 * 60 * 1000 + private const val MAX_PEERS = 200 + + @Volatile + private var instance: PrekeyManager? = null + + fun getInstance(context: Context): PrekeyManager = + instance ?: synchronized(this) { + instance ?: run { + val application = context.applicationContext + val identityState = SecureIdentityStateManager(application) + val random = SecureRandom() + PrekeyManager( + identity = AndroidPrekeyIdentity(identityState), + localStore = SecureLocalPrekeyStore(identityState), + peerStore = SharedPreferencesPeerPrekeyStore( + application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE) + ), + randomBytes = { + ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes) + } + ).also { instance = it } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt new file mode 100644 index 00000000..d62e0e29 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.services.bridge + +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.identity.SecureIdentityStateManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +internal data class LocalPrekeyRecord( + val id: Long, + val privateKey: String, + val createdAt: Long, + var consumedAt: Long? = null +) + +internal data class LocalPrekeyState( + var records: MutableList = mutableListOf(), + var nextId: Long = 0, + var generatedAt: Long = 0 +) + +internal data class StoredPeerPrekeyBundle( + val noiseKey: String, + var generatedAt: Long, + var prekeyIds: List, + var prekeyPublicKeys: List, + var usedIds: MutableSet, + var assignments: MutableMap, + var updatedAt: Long +) + +internal interface PrekeyIdentity { + fun staticKey(): Pair? + fun signingKey(): Pair? +} + +internal interface LocalPrekeyStore { + fun load(): LocalPrekeyState + fun save(state: LocalPrekeyState) + fun clear() +} + +internal interface PeerPrekeyStore { + fun load(): MutableMap + fun save(bundles: Map) + fun clear() +} + +internal class AndroidPrekeyIdentity( + private val state: SecureIdentityStateManager +) : PrekeyIdentity { + override fun staticKey(): Pair? = state.loadStaticKey() + override fun signingKey(): Pair? = state.loadSigningKey() +} + +internal class SecureLocalPrekeyStore( + private val state: SecureIdentityStateManager, + private val gson: Gson = Gson() +) : LocalPrekeyStore { + override fun load(): LocalPrekeyState = + runCatching { + state.getSecureValue(LOCAL_STORE_KEY) + ?.let { gson.fromJson(it, LocalPrekeyState::class.java) } + }.getOrNull() ?: LocalPrekeyState() + + override fun save(state: LocalPrekeyState) { + check(this.state.storeSecureValueSynchronously(LOCAL_STORE_KEY, gson.toJson(state))) { + "Unable to persist courier prekeys" + } + } + + override fun clear() { + check(state.clearSecureValuesSynchronously(LOCAL_STORE_KEY)) + } + + private companion object { + const val TAG = "LocalPrekeyStore" + const val LOCAL_STORE_KEY = "courier_prekeys_v1" + } +} + +internal class SharedPreferencesPeerPrekeyStore( + private val preferences: SharedPreferences, + private val gson: Gson = Gson() +) : PeerPrekeyStore { + override fun load(): MutableMap { + val type = object : TypeToken>() {}.type + val values: List = runCatching { + preferences.getString(PEER_BUNDLES_KEY, null) + ?.let { json -> gson.fromJson>(json, type) } + }.getOrNull() ?: emptyList() + return values + .filter { it.prekeyIds.size == it.prekeyPublicKeys.size } + .associateByTo(mutableMapOf()) { it.noiseKey } + } + + override fun save(bundles: Map) { + check(preferences.edit().putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())).commit()) + } + + override fun clear() { + check(preferences.edit().clear().commit()) + } + + private companion object { + const val PEER_BUNDLES_KEY = "bundles_v1" + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt index 785c249d..013660fd 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -76,6 +76,31 @@ class MeshGraphService private constructor() { } } + /** + * Returns a shortest path using only bidirectionally confirmed topology claims. + */ + fun computeRoute(fromPeerID: String, toPeerID: String): List? { + if (fromPeerID == toPeerID) return listOf(fromPeerID) + val adjacency = mutableMapOf>() + graphState.value.edges.filter(GraphEdge::isConfirmed).forEach { edge -> + adjacency.getOrPut(edge.a) { mutableSetOf() }.add(edge.b) + adjacency.getOrPut(edge.b) { mutableSetOf() }.add(edge.a) + } + val queue = ArrayDeque>() + val visited = mutableSetOf(fromPeerID) + queue.add(listOf(fromPeerID)) + while (queue.isNotEmpty()) { + val path = queue.removeFirst() + adjacency[path.last()].orEmpty().sorted().forEach { neighbor -> + if (!visited.add(neighbor)) return@forEach + val next = path + neighbor + if (neighbor == toPeerID) return next + queue.add(next) + } + } + return null + } + private fun publishSnapshot() { // Collect all known nodes from nicknames and announcements val allNodes = mutableSetOf() diff --git a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt index 212def6a..9d5b9fa2 100644 --- a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt +++ b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt @@ -21,9 +21,12 @@ object GCSFilter { data class Params( val p: Int, // Golomb-Rice parameter (>= 1) val m: Long, // Range M = N * 2^P - val data: ByteArray // Encoded GR bitstream + val data: ByteArray, // Encoded GR bitstream + val includedCount: Int // Number of newest-first input IDs actually encoded ) + const val MAX_P = 32 + // Derive P from target FPR; FPR ~= 1 / 2^P fun deriveP(targetFpr: Double): Int { val f = targetFpr.coerceIn(0.000001, 0.25) @@ -66,7 +69,12 @@ object GCSFilter { encoded = encode(mapped, p) } - return Params(p = p, m = finalM, data = encoded) + return Params( + p = p, + m = finalM, + data = encoded, + includedCount = if (encoded.isEmpty()) 0 else trimmedN + ) } fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray { @@ -196,4 +204,3 @@ object GCSFilter { } } } - diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..95f968e7 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap +import android.content.Context +import java.io.File +import java.nio.file.StandardCopyOption /** * Gossip-based synchronization manager using on-demand GCS filters. @@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap class GossipSyncManager( private val myPeerID: String, private val scope: CoroutineScope, - private val configProvider: ConfigProvider + private val configProvider: ConfigProvider, + context: Context? = null ) { interface Delegate { fun sendPacket(packet: BitchatPacket) @@ -33,8 +37,21 @@ class GossipSyncManager( companion object { private const val TAG = "GossipSyncManager" + const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L + const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L + const val PUBLIC_PACKET_FUTURE_SKEW_MS = 10 * 60 * 1000L + private const val ARCHIVE_FILE = "gossip-public-history.bin" } + var boardPacketsProvider: (() -> List)? = null + private val responseTimesByPeer = ConcurrentHashMap>() + private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet() + private fun peerSupportsBoard(peerID: String): Boolean = + peerID.lowercase() in observedBoardSyncPeers || + latestAnnouncementByPeer[peerID.lowercase()]?.second?.payload?.let { + com.bitchat.android.model.IdentityAnnouncement.decode(it)?.capabilities + ?.contains(com.bitchat.android.model.PeerCapabilities.BOARD) + } == true var delegate: Delegate? = null // Defaults (configurable constants) @@ -44,11 +61,16 @@ class GossipSyncManager( // Stored packets for sync: // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId private val messages = LinkedHashMap() + private val groupMessages = LinkedHashMap() + private val fragments = LinkedHashMap() + private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) } + private var restoringArchive = false // - announcements: only keep latest per sender peerID private val latestAnnouncementByPeer = ConcurrentHashMap>() private var periodicJob: Job? = null private var cleanupJob: Job? = null + init { restoreArchive() } fun start() { periodicJob?.cancel() periodicJob = scope.launch(Dispatchers.IO) { @@ -84,7 +106,12 @@ class GossipSyncManager( synchronized(messages) { messages.clear() } + synchronized(fragments) { fragments.clear() } + synchronized(groupMessages) { groupMessages.clear() } latestAnnouncementByPeer.clear() + responseTimesByPeer.clear() + observedBoardSyncPeers.clear() + archiveFile?.delete() Log.d(TAG, "Cleared all gossip sync messages and announcements") } @@ -106,13 +133,33 @@ 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 in setOf(MessageType.GROUP_MESSAGE, MessageType.PREKEY_BUNDLE) && + (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) - if (!isBroadcastMessage && !isAnnouncement) return + val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) && + (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + 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) { + val cacheKey = if (mt == MessageType.PREKEY_BUNDLE) "prekey-${packet.senderID.toHexString()}" else id + groupMessages[cacheKey] = 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 synchronized(messages) { messages[id] = packet // Enforce capacity (remove oldest when exceeded) @@ -122,6 +169,17 @@ class GossipSyncManager( if (it.hasNext()) { it.next(); it.remove() } else break } } + if (!restoringArchive) persistArchive() + } else if (isFragment) { + val age = System.currentTimeMillis() - packet.timestamp.toLong() + if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..FRAGMENT_MAX_AGE_MS) return + synchronized(fragments) { + fragments[id] = packet + while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) { + val iterator = fragments.entries.iterator() + if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break + } + } } else if (isAnnouncement) { // Ignore stale announcements older than STALE_PEER_TIMEOUT val now = System.currentTimeMillis() @@ -143,22 +201,35 @@ class GossipSyncManager( } private fun sendRequestSync() { - val payload = buildGcsPayload() - - val packet = BitchatPacket( - type = MessageType.REQUEST_SYNC.value, - senderID = hexStringToByteArray(myPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = payload, - ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only - ) - // Sign and broadcast - val signed = delegate?.signPacketForBroadcast(packet) ?: packet - delegate?.sendPacket(signed) + listOf( + SyncTypeFlags.PUBLIC_MESSAGES, + SyncTypeFlags.FRAGMENT, + SyncTypeFlags.FILE_TRANSFER, + SyncTypeFlags.GROUP_MESSAGE.union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE)) + ).forEach { types -> + val payload = buildGcsPayload(types) + val packet = BitchatPacket( + type = MessageType.REQUEST_SYNC.value, + senderID = hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only + ) + val signed = delegate?.signPacketForBroadcast(packet) ?: packet + delegate?.sendPacket(signed) + } + if (boardPacketsProvider != null) { + latestAnnouncementByPeer.keys.filter(::peerSupportsBoard).forEach { + sendRequestSyncToPeer(it, SyncTypeFlags.BOARD) + } + } } - private fun sendRequestSyncToPeer(peerID: String) { - val payload = buildGcsPayload() + private fun sendRequestSyncToPeer(peerID: String, requestedTypes: SyncTypeFlags? = null) { + val types = requestedTypes ?: SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + .union(SyncTypeFlags.GROUP_MESSAGE).union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE)) + .let { if (boardPacketsProvider != null && peerSupportsBoard(peerID)) it.union(SyncTypeFlags.BOARD) else it } + val payload = buildGcsPayload(types) val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, @@ -175,6 +246,12 @@ class GossipSyncManager( } fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { + val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES + if (requestedTypes.contains(MessageType.BOARD_POST) && latestAnnouncementByPeer.containsKey(fromPeerID.lowercase())) { + observedBoardSyncPeers.add(fromPeerID.lowercase()) + } + if (!shouldRespondTo(fromPeerID)) return + val sinceTimestamp = request.sinceTimestamp // Decode GCS into sorted set for membership checks val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) fun mightContain(id: ByteArray): Boolean { @@ -183,26 +260,85 @@ class GossipSyncManager( return GCSFilter.contains(sorted, nonZeroV) } - // 1) Announcements: send latest per peerID if remote doesn't have them - for ((_, pair) in latestAnnouncementByPeer.entries) { - val (id, pkt) = pair - val idBytes = hexToBytes(id) - if (!mightContain(idBytes)) { - // Send original packet unchanged to requester only (keep local TTL) - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + // Announcements are exempt from the cursor: only the latest per peer is retained, + // and peers need their signing keys before they can verify other sync responses. + if (requestedTypes.contains(MessageType.ANNOUNCE)) { + for ((_, pair) in latestAnnouncementByPeer.entries) { + val (id, pkt) = pair + val idBytes = hexToBytes(id) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + } } } - // 2) Broadcast messages: send all they lack - val toSendMsgs = synchronized(messages) { messages.values.toList() } - for (pkt in toSendMsgs) { + if (requestedTypes.contains(MessageType.MESSAGE)) { + val toSendMsgs = synchronized(messages) { messages.values.toList() } + for (pkt in toSendMsgs) { + if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue + val idBytes = PacketIdUtil.computeIdBytes(pkt) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + } + } + } + + if (requestedTypes.contains(MessageType.GROUP_MESSAGE) || requestedTypes.contains(MessageType.PREKEY_BUNDLE)) { + typedSyncCandidates(requestedTypes).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)) + } + } + } + if (requestedTypes.contains(MessageType.BOARD_POST)) { + boardPacketsProvider?.invoke().orEmpty().take(200).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 + if (!requestedTypes.contains(type)) continue + if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue val idBytes = PacketIdUtil.computeIdBytes(pkt) if (!mightContain(idBytes)) { - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)) + } + } + } + + private fun typedSyncCandidates(types: SyncTypeFlags): List = synchronized(groupMessages) { + val now = System.currentTimeMillis() + groupMessages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } + groupMessages.values.filter { packet -> + MessageType.fromValue(packet.type)?.let(types::contains) == true + } + } + + private fun shouldRespondTo(peerID: String): Boolean { + val now = System.currentTimeMillis() + responseTimesByPeer.entries.removeIf { (_, times) -> + synchronized(times) { times.isEmpty() || now - times.last() >= 30_000L } + } + if (responseTimesByPeer.size >= 256 && !responseTimesByPeer.containsKey(peerID)) return false + val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() } + return synchronized(times) { + while (times.isNotEmpty() && now - times.first() >= 30_000L) { + times.removeFirst() + } + if (times.size >= 8) { + false + } else { + times.addLast(now) + true } } } @@ -232,16 +368,25 @@ class GossipSyncManager( return out } - private fun buildGcsPayload(): ByteArray { - // Collect candidates: latest announcement per peer + recent broadcast messages + internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray { + // Collect only the packet types represented by this filter. val list = ArrayList() - // announcements - for ((_, pair) in latestAnnouncementByPeer) { - list.add(pair.second) + if (types.contains(MessageType.BOARD_POST)) list.addAll(boardPacketsProvider?.invoke().orEmpty().take(200)) + list.addAll(typedSyncCandidates(types)) + if (types.contains(MessageType.ANNOUNCE)) { + for ((_, pair) in latestAnnouncementByPeer) { + list.add(pair.second) + } } - // messages - synchronized(messages) { - list.addAll(messages.values) + if (types.contains(MessageType.MESSAGE)) { + synchronized(messages) { + list.addAll(messages.values) + } + } + synchronized(fragments) { + list.addAll(fragments.values.filter { packet -> + MessageType.fromValue(packet.type)?.let(types::contains) == true + }) } // sort by timestamp desc, then take up to min(seenCapacity, fit capacity) list.sortByDescending { it.timestamp.toLong() } @@ -254,15 +399,24 @@ class GossipSyncManager( val takeN = minOf(nMax, cap, list.size) if (takeN <= 0) { val p0 = GCSFilter.deriveP(fpr) - return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode() + return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0), types = types).encode() } - val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) } + val included = list.take(takeN) + val ids = included.map { pkt -> PacketIdUtil.computeIdBytes(pkt) } val params = GCSFilter.buildFilter(ids, maxBytes, fpr) val mVal = if (params.m <= 0L) 1 else params.m - return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode() + val covered = params.includedCount + val sinceTimestamp = if (covered in 1 until list.size) included[covered - 1].timestamp else null + return RequestSyncPacket( + p = params.p, + m = mVal, + data = params.data, + types = types, + sinceTimestamp = sinceTimestamp + ).encode() } - // Periodically remove stale announcements and all their messages + // Announcements age out quickly; public history remains independently sync-able for six hours. private fun pruneStaleAnnouncements() { val now = System.currentTimeMillis() val stalePeers = mutableListOf() @@ -276,26 +430,17 @@ class GossipSyncManager( } } - if (stalePeers.isEmpty()) return - - // Remove announcements and their messages - var totalPrunedMsgs = 0 + var changed = false for (peerID in stalePeers) { - // Count messages to be pruned for logging - val toRemove = mutableListOf() - synchronized(messages) { - for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } - if (sender == peerID) toRemove.add(id) - } - } - totalPrunedMsgs += toRemove.size - - // Reuse existing removal which also clears announcement entry - removeAnnouncementForPeer(peerID) + changed = latestAnnouncementByPeer.remove(peerID) != null || changed } - - Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages") + synchronized(messages) { + changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed + } + synchronized(fragments) { + fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS } + } + if (changed) persistArchive() } // Explicitly remove stored announcement for a given peer (hex ID) @@ -305,26 +450,66 @@ class GossipSyncManager( Log.d(TAG, "Removed stored announcement for peer $peerID") } - // Collect IDs to remove first to avoid modifying collection while iterating - val idsToRemove = mutableListOf() - synchronized(messages) { - for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } - if (sender == key) { - idsToRemove.add(id) + } + + private fun restoreArchive() { + val file = archiveFile ?: return + if (!file.exists()) return + try { + restoringArchive = true + java.io.DataInputStream(file.inputStream().buffered()).use { input -> + val count = input.readInt() + require(count in 0..configProvider.seenCapacity()) + repeat(count) { + val length = input.readInt() + require(length in 1..(com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256)) + val bytes = ByteArray(length) + input.readFully(bytes) + val packet = com.bitchat.android.protocol.BinaryProtocol.decode(bytes) ?: return@repeat + onPublicPacketSeen(packet) } } - } - - // Now remove the collected IDs - synchronized(messages) { - for (id in idsToRemove) { - messages.remove(id) - } - } - - if (idsToRemove.isNotEmpty()) { - Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements") + } catch (_: Exception) { + synchronized(messages) { messages.clear() } + } finally { + restoringArchive = false } } + + @Synchronized + private fun persistArchive() { + val file = archiveFile ?: return + try { + val packets = synchronized(messages) { messages.values.toList() } + val temporary = File(file.parentFile, "${file.name}.tmp") + java.io.DataOutputStream(temporary.outputStream().buffered()).use { output -> + output.writeInt(packets.size) + packets.forEach { packet -> + val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach + output.writeInt(encoded.size) + output.write(encoded) + } + } + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + // Some Android filesystems do not support ATOMIC_MOVE. Preserve the durable + // public-history guarantee with a regular replacement move before giving up. + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + temporary.delete() + } + } + } catch (_: Exception) { } + } } diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt new file mode 100644 index 00000000..f7cf1504 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -0,0 +1,80 @@ +package com.bitchat.android.sync + +import com.bitchat.android.protocol.MessageType + +/** Compact little-endian bitfield matching iOS SyncTypeFlags. */ +@JvmInline +value class SyncTypeFlags private constructor(val rawValue: ULong) { + companion object { + private const val KNOWN_TYPE_MASK: ULong = 0x7ffu + + val BOARD = fromMessageTypes(MessageType.BOARD_POST) + val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE) + fun of(vararg types: MessageType) = fromMessageTypes(*types) + + val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE) + val MESSAGE = fromMessageTypes(MessageType.MESSAGE) + val FRAGMENT = fromMessageTypes(MessageType.FRAGMENT) + val FILE_TRANSFER = fromMessageTypes(MessageType.FILE_TRANSFER) + val PUBLIC_MESSAGES = fromMessageTypes(MessageType.ANNOUNCE, MessageType.MESSAGE) + val FRAGMENTS_AND_FILES = fromMessageTypes(MessageType.FRAGMENT, MessageType.FILE_TRANSFER) + + fun fromRawValue(rawValue: ULong): SyncTypeFlags = SyncTypeFlags(rawValue and KNOWN_TYPE_MASK) + + fun fromMessageTypes(vararg types: MessageType): SyncTypeFlags { + var rawValue = 0uL + types.forEach { type -> + bitIndex(type)?.let { bit -> rawValue = rawValue or (1uL shl bit) } + } + return fromRawValue(rawValue) + } + + fun decode(data: ByteArray): SyncTypeFlags? { + if (data.size !in 1..8) return null + var rawValue = 0uL + data.forEachIndexed { index, byte -> + rawValue = rawValue or (byte.toUByte().toULong() shl (index * 8)) + } + return fromRawValue(rawValue) + } + + private fun bitIndex(type: MessageType): Int? = when (type) { + MessageType.ANNOUNCE -> 0 + MessageType.MESSAGE -> 1 + MessageType.LEAVE -> 2 + MessageType.NOISE_HANDSHAKE -> 3 + MessageType.NOISE_ENCRYPTED -> 4 + MessageType.FRAGMENT -> 5 + MessageType.REQUEST_SYNC -> 6 + MessageType.FILE_TRANSFER -> 7 + MessageType.BOARD_POST -> 8 + MessageType.PREKEY_BUNDLE -> 9 + MessageType.GROUP_MESSAGE -> 10 + MessageType.PING, + MessageType.PONG, + MessageType.NOSTR_CARRIER, + MessageType.COURIER_ENVELOPE, + MessageType.VOICE_FRAME -> null + } + } + + fun contains(type: MessageType): Boolean { + val bit = bitIndex(type) ?: return false + return (rawValue and (1uL shl bit)) != 0uL + } + + 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 + val bytes = ArrayList(8) + while (remaining != 0uL && bytes.size < 8) { + bytes += (remaining and 0xffu).toByte() + remaining = remaining shr 8 + } + return bytes.toByteArray() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 54ab0318..573831fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -303,11 +303,14 @@ private fun SettingsToggleRow( fun AboutSheet( isPresented: Boolean, onDismiss: () -> Unit, + bridgeEnabled: Boolean, + onBridgeEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, onShowDebug: (() -> Unit)? = null, - modifier: Modifier = Modifier + onShowMeshTopology: (() -> Unit)? = null ) { val context = LocalContext.current - + // Get version name from package info val versionName = remember { try { @@ -445,6 +448,8 @@ fun AboutSheet( } } + item(key = "client_privacy") { ClientSettingsSection() } + item(key = "language") { val selectedLanguageName = supportedLanguages .firstOrNull { it.languageTag == selectedLanguageTag } @@ -544,6 +549,19 @@ fun AboutSheet( } ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + SettingsToggleRow( + icon = Icons.Filled.Public, + title = stringResource(R.string.mesh_bridge_title), + subtitle = stringResource(R.string.mesh_bridge_description), + checked = bridgeEnabled, + onCheckedChange = onBridgeEnabledChange + ) + HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, @@ -1256,6 +1274,16 @@ fun AboutSheet( ) } } + if (onShowMeshTopology != null) { + TextButton(onClick = onShowMeshTopology) { + Text( + text = "network → mesh topology", + fontSize = 13.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary, + ) + } + } Text( text = stringResource(R.string.about_footer), fontSize = 11.sp, @@ -1303,7 +1331,7 @@ fun PasswordPromptDialog( ) { if (show && channelName != null) { val colorScheme = MaterialTheme.colorScheme - + AlertDialog( onDismissRequest = onDismiss, title = { @@ -1321,7 +1349,7 @@ fun PasswordPromptDialog( color = colorScheme.onSurface ) Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( value = passwordInput, onValueChange = onPasswordChange, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index aac5283c..a1e322ce 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -355,9 +355,9 @@ fun NoiseSessionIcon( ) } - // Closed once the handshake resolves (success or failure); open while idle or in flight. + // A closed lock only represents an established encrypted session. val lockIconRes = when { - sessionState == "established" || sessionState?.startsWith("failed") == true -> + sessionState == "established" -> R.drawable.ic_spec_lock else -> R.drawable.ic_spec_lock_open } @@ -564,6 +564,7 @@ fun NicknameEditor( @Composable fun PeerCounter( connectedPeers: List, + bridgedPeopleCount: Int = 0, joinedChannels: Set, hasUnreadChannels: Map, isConnected: Boolean, @@ -586,8 +587,8 @@ fun PeerCounter( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) - val count = connectedPeers.size - Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) + val count = connectedPeers.size + bridgedPeopleCount + Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) colorScheme.secondary else palette.textTertiary) } } @@ -727,6 +728,7 @@ private fun MainHeader( val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val crowdingMode = headerCrowdingMode(maxWidth) @@ -809,6 +811,7 @@ private fun MainHeader( PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, + bridgedPeopleCount = bridgeUiState.participants.size, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, isConnected = isConnected, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..40660cb3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -83,6 +83,10 @@ fun ChatScreen(viewModel: ChatViewModel) { val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() + + val panicWipeState by viewModel.panicWipeState.collectAsStateWithLifecycle() + PanicWipeDialog(panicWipeState, viewModel::panicClearAllData, viewModel::dismissPanicClear) var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -107,6 +111,15 @@ fun ChatScreen(viewModel: ChatViewModel) { ) } + val sharedDraft by viewModel.sharedDraft.collectAsStateWithLifecycle() + LaunchedEffect(sharedDraft) { + sharedDraft?.let { shared -> + val combined = listOf(messageText.text, shared).filter { it.isNotBlank() }.joinToString("\n") + messageText = TextFieldValue(combined, androidx.compose.ui.text.TextRange(combined.length)) + viewModel.consumeSharedText() + } + } + // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { showPasswordDialog = showPasswordPrompt @@ -307,7 +320,7 @@ fun ChatScreen(viewModel: ChatViewModel) { conversationKey = conversationKey, contentPadding = PaddingValues( top = statusBarHeight + headerHeight + - (if (showNotesStrip) notesStripHeight else 0.dp), + notesStripHeight, bottom = composerHeight ), forceScrollToBottom = forceScrollToBottom, @@ -361,16 +374,13 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - if (showNotesStrip) { - NearbyNotesStrip( + Column(modifier = Modifier.align(Alignment.TopCenter) + .padding(top = statusBarHeight + headerHeight) + .onSizeChanged { notesStripHeight = with(density) { it.height.toDp() } }) { + ConnectivityBanner() + if (showNotesStrip) NearbyNotesStrip( noteCount = nearbyNotes.size, - onClick = { showLocationNotesSheet = true }, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = statusBarHeight + headerHeight) - .onSizeChanged { size -> - notesStripHeight = with(density) { size.height.toDp() } - }, + onClick = { showLocationNotesSheet = true } ) } @@ -443,7 +453,12 @@ fun ChatScreen(viewModel: ChatViewModel) { currentChannel = currentChannel, nickname = nickname, colorScheme = colorScheme, - showMediaButtons = showMediaButtons + showMediaButtons = showMediaButtons, + showBridgeControls = bridgeUiState.enabled && + currentChannel == null && + selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location, + nearbyOnly = bridgeUiState.nearbyOnly, + onNearbyOnlyChange = viewModel::setBridgeNearbyOnly ) } } @@ -457,7 +472,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, onSidebarToggle = { viewModel.showMeshPeerList() }, onShowAppInfo = { viewModel.showAppInfo() }, - onPanicClear = { viewModel.panicClearAllData() }, + onPanicClear = { viewModel.requestPanicClear() }, onLocationChannelsClick = { showLocationChannelsSheet = true }, onLocationNotesClick = { nearbyNotesController.reveal() @@ -645,7 +660,10 @@ fun ChatInputSection( colorScheme: ColorScheme, showMediaButtons: Boolean, recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showBridgeControls: Boolean = false, + nearbyOnly: Boolean = false, + onNearbyOnlyChange: (Boolean) -> Unit = {} ) { val context = androidx.compose.ui.platform.LocalContext.current val activePublicTalker by remember(context) { @@ -726,6 +744,9 @@ fun ChatInputSection( currentChannel = currentChannel, nickname = nickname, showMediaButtons = showMediaButtons, + showBridgeControls = showBridgeControls, + nearbyOnly = nearbyOnly, + onNearbyOnlyChange = onNearbyOnlyChange, mentionPeerIdentities = mentionPeerIdentities, recorderFactory = recorderFactory, activePublicTalker = activePublicTalker, @@ -840,6 +861,7 @@ private fun ChatDialogs( onMeshPeerListDismiss: () -> Unit, ) { val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() // Password dialog PasswordPromptDialog( @@ -853,10 +875,14 @@ private fun ChatDialogs( // About sheet var showDebugSheet by remember { mutableStateOf(false) } + var showMeshTopology by remember { mutableStateOf(false) } AboutSheet( isPresented = showAppInfo, onDismiss = onAppInfoDismiss, - onShowDebug = { showDebugSheet = true } + onShowDebug = { showDebugSheet = true }, + bridgeEnabled = bridgeUiState.enabled, + onBridgeEnabledChange = viewModel::setBridgeEnabled, + onShowMeshTopology = { showMeshTopology = true }, ) if (showDebugSheet) { com.bitchat.android.ui.debug.DebugSettingsSheet( @@ -865,6 +891,11 @@ private fun ChatDialogs( meshService = viewModel.meshService ) } + MeshTopologySheet( + isPresented = showMeshTopology, + onDismiss = { showMeshTopology = false }, + meshService = viewModel.meshServiceFacade, + ) // Location channels sheet if (showLocationChannelsSheet) { @@ -876,9 +907,9 @@ private fun ChatDialogs( ) } - // Location notes sheet (extracted to separate presenter) + // Unified mesh and geohash notices sheet. if (showLocationNotesSheet) { - LocationNotesSheetPresenter( + NoticesSheetPresenter( viewModel = viewModel, onDismiss = onLocationNotesSheetDismiss ) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index f40549b6..c6aed279 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import android.app.Application import android.util.Log +import com.bitchat.android.R import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -39,10 +40,25 @@ import com.bitchat.android.noise.NoiseSession import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +import com.bitchat.android.services.bridge.BridgeUiState +import com.bitchat.android.services.bridge.MeshBridgeService 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 + +import com.bitchat.android.board.BoardManager +import com.bitchat.android.board.BoardSigningIdentity +import com.bitchat.android.board.BoardStore + private data class ConversationLiveIdentityState( val connectedPeerIDs: List, val peerNicknames: Map, @@ -70,6 +86,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 · invite @name[#identity] · " + + "remove @name[#identity] · leave · list" } fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { @@ -151,6 +170,10 @@ class ChatViewModel( .select(com.bitchat.android.geohash.ChannelID.Mesh) } ) + private val groupRuntime = com.bitchat.android.groups.GroupRuntime.getInstance(application) + private val groupStore = groupRuntime.store + private val groupCoordinator = groupRuntime.coordinator + val groups: StateFlow> = groupStore.groups // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { @@ -178,11 +201,87 @@ class ChatViewModel( privateChatManager, viewModelScope ) + val boardManager = BoardManager( + store = BoardStore.getInstance(application.applicationContext), + scope = viewModelScope, + meshProvider = { mesh }, + geoIdentityProvider = { geohash -> + runCatching { + NostrIdentityBridge.deriveIdentity( + forGeohash = geohash, + context = application.applicationContext + ) + }.getOrNull()?.let { identity -> + BoardSigningIdentity.fromNostrPrivateKeyHex(identity.privateKeyHex) + } + }, + onUrgentPosts = { geohash, posts -> + val text = if (posts.size == 1) { + val post = posts.single() + application.getString( + R.string.notices_alert_urgent_single, + post.authorNickname.trim().ifEmpty { "anon" }, + post.content.truncateNoticeAlert() + ) + } else { + application.getString( + R.string.notices_alert_urgent_collapsed, + posts.size + ) + } + if (geohash.isEmpty()) { + messageManager.addSystemMessage(text) + } else { + messageManager.addChannelMessage( + "geo:$geohash", + BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false + ) + ) + } + } + ) private val notificationManager = NotificationManager( application.applicationContext, NotificationManagerCompat.from(application.applicationContext) ) + private val groupContext = object : com.bitchat.android.groups.GroupUiDelegate { + override val nickname get() = state.getNicknameValue() + override fun markGroupUnread(groupPeerID: String) { + state.setUnreadPrivateMessages(state.getUnreadPrivateMessagesValue() + groupPeerID) + } + override fun openGroupConversation(groupPeerID: String) { + privateChatManager.startPrivateChat(groupPeerID, mesh) + showPrivateChatSheet(groupPeerID) + } + override fun closeGroupConversation() { endPrivateChat() } + } + + fun executeGroupCommand(arguments: List, onResult: (GroupCommandResult) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val result = handleGroupCommand(arguments) + kotlinx.coroutines.withContext(Dispatchers.Main) { onResult(result) } + } + } + + private fun handleGroupCommand(arguments: List): 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, @@ -193,6 +292,7 @@ class ChatViewModel( messageManager = messageManager ) val verifiedFingerprints = verificationHandler.verifiedFingerprints + val vouchedFingerprints = verificationHandler.vouchedFingerprints // Media file sending manager private val mediaSendingManager = MediaSendingManager( @@ -410,6 +510,21 @@ class ChatViewModel( val geohashPeople: StateFlow> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val bridgeUiState: StateFlow = combine( + MeshBridgeService.isEnabled, + MeshBridgeService.nearbyOnly, + MeshBridgeService.bridgedParticipants + ) { enabled, nearbyOnly, participants -> + BridgeUiState(enabled, nearbyOnly, participants) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = BridgeUiState( + enabled = MeshBridgeService.isEnabled.value, + nearbyOnly = MeshBridgeService.nearbyOnly.value, + participants = MeshBridgeService.bridgedParticipants.value + ) + ) val meshServiceFacade: MeshService get() = mesh val myPeerID: String @@ -425,6 +540,7 @@ class ChatViewModel( init { observeConversationPresenceWithDisconnectGrace() + groupRuntime.attach(groupContext) // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } @@ -646,6 +762,7 @@ class ChatViewModel( } override fun onCleared() { + groupRuntime.detach(groupContext) if (favoriteRelationshipListenerRegistered) { runCatching { FavoritesPersistenceService.shared.removeListener( @@ -976,6 +1093,14 @@ class ChatViewModel( val currentChannelValue = state.getCurrentChannelValue() if (selectedPeer != null) { + if (GroupIds.isGroup(selectedPeer)) { + val groupPeer = selectedPeer + viewModelScope.launch(Dispatchers.IO) { + val accepted = groupCoordinator.sendMessage(content, groupPeer) + kotlinx.coroutines.withContext(Dispatchers.Main) { onAccepted(accepted) } + } + 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( @@ -1020,6 +1145,11 @@ class ChatViewModel( messageId, com.bitchat.android.model.DeliveryStatus.Sent ) + } else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) { + messageManager.updateMessageDeliveryStatus( + messageId, + com.bitchat.android.model.DeliveryStatus.Queued + ) } } onAccepted(accepted) @@ -1269,6 +1399,20 @@ class ChatViewModel( return verifiedFingerprints.contains(fingerprint) } + fun isFingerprintVouched(fingerprint: String): Boolean = + verificationHandler.isFingerprintVouched(fingerprint) + + fun isNoisePublicKeyVouched(noisePublicKey: ByteArray): Boolean = + verificationHandler.isFingerprintVouched( + verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) + ) + + fun vouchersForFingerprint(fingerprint: String) = + verificationHandler.vouchersForFingerprint(fingerprint) + + fun voucherNamesForFingerprint(fingerprint: String): List = + verificationHandler.voucherNamesForFingerprint(fingerprint) + fun unverifyFingerprint(peerID: String) { verificationHandler.unverifyFingerprint(peerID) } @@ -1332,6 +1476,14 @@ class ChatViewModel( state.setShowMeshPeerList(true) } + fun setBridgeEnabled(enabled: Boolean) { + MeshBridgeService.setEnabled(enabled) + } + + fun setBridgeNearbyOnly(enabled: Boolean) { + MeshBridgeService.setNearbyOnly(enabled) + } + fun hideMeshPeerList() { state.setShowMeshPeerList(false) } @@ -1421,6 +1573,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? { @@ -1435,102 +1608,78 @@ class ChatViewModel( return meshDelegateHandler.isFavorite(peerID) } + private val _sharedDraft = MutableStateFlow(null) + val sharedDraft: StateFlow = _sharedDraft.asStateFlow() + fun receiveSharedText(text: String) { _sharedDraft.value = text.take(16_000) } + fun consumeSharedText() { _sharedDraft.value = null } + // MARK: - Emergency Clear - private var panicClearInProgress = false + private val _panicWipeState = MutableStateFlow(PanicWipeState.IDLE) + val panicWipeState: StateFlow = _panicWipeState.asStateFlow() + fun requestPanicClear() { + if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.CONFIRM + } + fun dismissPanicClear() { + if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.IDLE + } fun panicClearAllData() { - if (panicClearInProgress) return - panicClearInProgress = true + if (_panicWipeState.value == PanicWipeState.ERASING) return + _panicWipeState.value = PanicWipeState.ERASING viewModelScope.launch { - try { - performPanicClearAllData() - } finally { - panicClearInProgress = false - } + val completed = try { performPanicClearAllData() } catch (_: Exception) { false } + _panicWipeState.value = if (completed) PanicWipeState.COMPLETE else PanicWipeState.FAILED } } - private suspend fun performPanicClearAllData() { - Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") - try { - com.bitchat.android.geohash.LocationChannelManager - .getInstance(getApplication()) - .disableLocationServices() - } catch (_: Exception) { } - - // A pending one-shot downgrade confirmation must not survive panic or - // become actionable against the fresh post-wipe identity. - mediaSendingManager.clearPendingPrivateMediaConsent() - - // Stop all message admission before wiping storage. The AppStateStore gate also rejects - // any transport callback already in flight until the fresh identity is ready. - clearAllMeshServiceData() - val conversationsCleared = - com.bitchat.android.services.AppStateStore - .panicClearPrivateConversations() - - // Clear all UI managers - com.bitchat.android.services.AppStateStore.clear() - messageManager.clearAllMessages() - channelManager.clearAllChannels() - privateChatManager.clearAllPrivateChats() - dataManager.clearAllData() - conversationListPreferences.clearAll() - - // Clear seen message store and MessageRouter outbox - try { - com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() - } catch (_: Exception) { } - try { - com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() - } catch (_: Exception) { } - - // Clear all cryptographic data - clearAllCryptographicData() - - // Clear all notifications - notificationManager.clearAllNotifications(removeConversationShortcuts = true) - - // Clear all media files - com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) - - // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch - try { - // Clear geohash bookmarks too (panic should remove everything) - try { - val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) - store.clearAll() - } catch (_: Exception) { } - - try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - locationManager.clearPersistedChannel() - } catch (_: Exception) { } - - geohashViewModel.panicReset() - } catch (e: Exception) { - Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") + private suspend fun performPanicClearAllData(): Boolean { + var successful = true + suspend fun step(action: suspend () -> Unit) { + try { action() } catch (_: Exception) { successful = false } } + groupCoordinator.suspendForPanic() + step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).disableLocationServices() } + step { com.bitchat.android.services.bridge.MeshGatewayService.wipe() } + step { MeshBridgeService.wipe() } + step { mesh.stopServices(); mesh.clearAllInternalData() } + step { check(com.bitchat.android.services.AppStateStore.panicClearPrivateConversations()) } + step { com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.wipe() } + mediaSendingManager.clearPendingPrivateMediaConsent() + step { + com.bitchat.android.services.AppStateStore.clear() + messageManager.clearAllMessages() + channelManager.clearAllChannels() + privateChatManager.clearAllPrivateChats() + check(groupStore.wipe()) + dataManager.clearAllData() + conversationListPreferences.clearAll() + boardManager.clearTransientState() + } + step { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } + step { com.bitchat.android.services.MessageRouter.panicClear(getApplication()) } + step { + mesh.clearAllEncryptionData() + check(SecureIdentityStateManager(getApplication()).clearIdentityData()) + FavoritesPersistenceService.shared.clearAllFavorites() + } + step { notificationManager.clearAllNotifications(removeConversationShortcuts = true) } + step { check(com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())) } + step { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()).clearAll() } + step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).panicReset() } + step { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearCustomRelays() } + step { geohashViewModel.panicReset() } + if (!successful) return false - // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - - if (!conversationsCleared) { - // 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") - return - } - - // Recreate mesh service with fresh identity - com.bitchat.android.services.AppStateStore - .resumePrivateConversationsAfterPanic() recreateMeshServiceAfterPanic() - - Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") + com.bitchat.android.services.AppStateStore.resumePrivateConversationsAfterPanic() + groupCoordinator.resumeAfterPanic() + MeshBridgeService.resumeAfterPanic() + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.resume() + return true } /** @@ -1562,53 +1711,6 @@ class ChatViewModel( ) } - /** - * Clear all mesh service related data - */ - private fun clearAllMeshServiceData() { - try { - // Request mesh service to clear all its internal data - mesh.clearAllInternalData() - - Log.d(TAG, "✅ Cleared all mesh service data") - } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}") - } - } - - /** - * Clear all cryptographic data including persistent identity - */ - private fun clearAllCryptographicData() { - try { - // Clear encryption service persistent identity (Ed25519 signing keys) - mesh.clearAllEncryptionData() - - // Clear secure identity state (if used) - try { - val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.clearIdentityData() - // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } - Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") - } catch (e: Exception) { - Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") - } - - // Clear FavoritesPersistenceService persistent relationships - try { - FavoritesPersistenceService.shared.clearAllFavorites() - Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } - - Log.d(TAG, "✅ Cleared all cryptographic data") - } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") - } - } - /** * Get participant count for a specific geohash (5-minute activity window) */ @@ -1730,4 +1832,6 @@ class ChatViewModel( fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) + private fun String.truncateNoticeAlert(): String = + if (length <= 120) this else take(120) + "…" } diff --git a/app/src/main/java/com/bitchat/android/ui/ClientPrivacyPreferences.kt b/app/src/main/java/com/bitchat/android/ui/ClientPrivacyPreferences.kt new file mode 100644 index 00000000..762680fd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ClientPrivacyPreferences.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.ui + +import android.content.Context + +object ClientPrivacyPreferences { + fun showNotificationPreviews(context: Context): Boolean = + context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).getBoolean("notification_previews", false) + + fun setNotificationPreviews(context: Context, enabled: Boolean) { + context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).edit() + .putBoolean("notification_previews", enabled).apply() + NotificationManager.clearAllForPrivacyChange(context) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ClientSettingsSection.kt b/app/src/main/java/com/bitchat/android/ui/ClientSettingsSection.kt new file mode 100644 index 00000000..9207e9c8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ClientSettingsSection.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.nostr.NostrRelayManager + +@Composable +fun ClientSettingsSection() { + val context = LocalContext.current + val relayManager = remember { NostrRelayManager.getInstance(context) } + val relays by relayManager.customRelays.collectAsStateWithLifecycle() + val gateway by com.bitchat.android.services.bridge.MeshGatewayService.enabled.collectAsStateWithLifecycle() + var previews by remember { mutableStateOf(ClientPrivacyPreferences.showNotificationPreviews(context)) } + var relayInput by remember { mutableStateOf("") } + var relayError by remember { mutableStateOf(false) } + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.notification_previews), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.notification_previews_description), style = MaterialTheme.typography.bodySmall) + } + Switch(checked = previews, onCheckedChange = { + previews = it + ClientPrivacyPreferences.setNotificationPreviews(context, it) + }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.gateway_title), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.gateway_description), style = MaterialTheme.typography.bodySmall) + } + Switch(gateway, onCheckedChange = com.bitchat.android.services.bridge.MeshGatewayService::setEnabled) + } + Text(stringResource(R.string.custom_relays), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.custom_relays_description), style = MaterialTheme.typography.bodySmall) + relays.forEach { url -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(url, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { relayManager.removeCustomRelay(url) }) { Text(stringResource(R.string.relay_remove)) } + } + } + OutlinedTextField( + value = relayInput, + onValueChange = { relayInput = it; relayError = false }, + label = { Text(stringResource(R.string.relay_url)) }, + placeholder = { Text("wss://relay.example") }, + singleLine = true, + isError = relayError, + modifier = Modifier.fillMaxWidth() + ) + if (relayError) Text(stringResource(R.string.relay_invalid), color = MaterialTheme.colorScheme.error) + TextButton(onClick = { + relayError = !relayManager.addCustomRelay(relayInput) + if (!relayError) relayInput = "" + }, enabled = relayInput.isNotBlank()) { Text(stringResource(R.string.relay_add)) } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 1cccfb19..463ae97e 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -1,7 +1,9 @@ package com.bitchat.android.ui +import com.bitchat.android.geohash.ChannelID import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.services.meshgraph.MeshGraphService import java.util.Date import java.util.Locale import kotlinx.coroutines.CoroutineScope @@ -23,11 +25,14 @@ 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(), "", "manage private groups"), CommandSuggestion("/hug", emptyList(), "", "send someone a warm hug"), CommandSuggestion("/j", listOf("/join"), "", "join or create a channel"), CommandSuggestion("/m", listOf("/msg"), " [message]", "send private message"), CommandSuggestion("/pay", emptyList(), " [public]", "send a Cashu ecash token"), + CommandSuggestion("/ping", emptyList(), "", "measure mesh round-trip time"), CommandSuggestion("/slap", emptyList(), "", "slap someone with a trout"), + CommandSuggestion("/trace", emptyList(), "", "estimate the mesh path"), CommandSuggestion("/unblock", emptyList(), "", "unblock a peer"), CommandSuggestion("/w", emptyList(), null, "see who's online") ) @@ -51,11 +56,50 @@ 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) + "/ping" -> handlePingCommand(parts, meshService) + "/trace" -> handleTraceCommand(parts, meshService) else -> handleUnknownCommand(cmd) } return true } + + private fun handleGroupCommand(parts: List, 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 + } + + viewModel.executeGroupCommand(parts.drop(1).filter(String::isNotBlank)) { result -> + 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, myPeerID: String) { if (parts.size > 1) { @@ -454,6 +498,104 @@ class CommandProcessor( else -> messageManager.addMessage(message) } } + private fun handlePingCommand(parts: List, meshService: MeshService) { + if (!isMeshContext()) { + addCommandOutput("mesh diagnostics are only available in #mesh") + return + } + val targetName = parts.getOrNull(1)?.removePrefix("@") + val peerID = targetName?.let { getPeerIDForNickname(it, meshService) } + if (targetName == null) { + addCommandOutput("usage: /ping ") + return + } + if (peerID == null) { + addCommandOutput("user '$targetName' not found") + return + } + val destination = currentCommandDestination() + addCommandOutput("pinging $targetName…", destination) + meshService.sendMeshPing(peerID) { result -> + val output = if (result == null) { + "no reply from $targetName" + } else { + val hops = if (result.hopCount == 1) { + "direct (1 hop)" + } else { + "${result.hopCount} hops" + } + "pong from $targetName: ${result.rttMillis} ms · $hops" + } + addCommandOutput(output, destination) + } + } + + private fun handleTraceCommand(parts: List, meshService: MeshService) { + if (!isMeshContext()) { + addCommandOutput("mesh diagnostics are only available in #mesh") + return + } + val targetName = parts.getOrNull(1)?.removePrefix("@") + val peerID = targetName?.let { getPeerIDForNickname(it, meshService) } + if (targetName == null) { + addCommandOutput("usage: /trace ") + return + } + if (peerID == null) { + addCommandOutput("user '$targetName' not found") + return + } + val graph = MeshGraphService.getInstance() + val route = graph.computeRoute(meshService.myPeerID, peerID) + ?: meshService.getPeerInfo(peerID) + ?.takeIf { it.isConnected && it.isDirectConnection } + ?.let { listOf(meshService.myPeerID, peerID) } + if (route == null) { + addCommandOutput("no known path to $targetName") + return + } + val labels = route.mapIndexed { index, id -> + when { + index == 0 -> "you" + id == peerID -> targetName + else -> meshService.getPeerNicknames()[id] ?: id.take(8) + } + } + val hopCount = route.size - 1 + val hops = if (hopCount == 1) "1 hop" else "$hopCount hops" + addCommandOutput("estimated path: ${labels.joinToString(" → ")} ($hops)") + } + + private sealed interface CommandDestination { + data object Main : CommandDestination + data class Channel(val name: String) : CommandDestination + data class Private(val peerID: String) : CommandDestination + } + + private fun currentCommandDestination(): CommandDestination = + state.getSelectedPrivateChatPeerValue()?.let(CommandDestination::Private) + ?: state.getCurrentChannelValue()?.let(CommandDestination::Channel) + ?: CommandDestination.Main + + private fun addCommandOutput( + text: String, + destination: CommandDestination = currentCommandDestination(), + ) { + val message = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + ) + when (destination) { + CommandDestination.Main -> messageManager.addMessage(message) + is CommandDestination.Channel -> messageManager.addChannelMessage(destination.name, message) + is CommandDestination.Private -> messageManager.addPrivateMessage(destination.peerID, message) + } + } + + private fun isMeshContext(): Boolean = + state.selectedLocationChannel.value.let { it == null || it is ChannelID.Mesh } private fun handleUnknownCommand(cmd: String) { val systemMessage = BitchatMessage( @@ -517,7 +659,10 @@ 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") || + (!isMeshContext() && it.command in setOf("/ping", "/trace")) || + (it.command == "/group" && (state.selectedLocationChannel.value is ChannelID.Location || + state.getSelectedPrivateChatPeerValue()?.startsWith("nostr") == true)) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ConnectivityBanner.kt b/app/src/main/java/com/bitchat/android/ui/ConnectivityBanner.kt new file mode 100644 index 00000000..eac63629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConnectivityBanner.kt @@ -0,0 +1,62 @@ +package com.bitchat.android.ui + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode + +@Composable +fun ConnectivityBanner() { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + fun bluetoothReady(): Boolean = runCatching { + if (Build.VERSION.SDK_INT >= 31 && ContextCompat.checkSelfPermission(context, + Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) false + else context.getSystemService(BluetoothManager::class.java)?.adapter?.isEnabled == true + }.getOrDefault(false) + var bluetoothEnabled by remember { mutableStateOf(bluetoothReady()) } + val tor by remember { ArtiTorManager.getInstance().statusFlow }.collectAsStateWithLifecycle() + DisposableEffect(context, lifecycle) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { bluetoothEnabled = bluetoothReady() } + } + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) bluetoothEnabled = bluetoothReady() + } + ContextCompat.registerReceiver(context, receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED) + lifecycle.addObserver(observer) + onDispose { context.unregisterReceiver(receiver); lifecycle.removeObserver(observer) } + } + val warning = when { + !bluetoothEnabled -> stringResource(R.string.bluetooth_unavailable_banner) + tor.mode == TorMode.ON && tor.state == ArtiTorManager.TorState.ERROR -> stringResource(R.string.tor_failed_banner) + tor.mode == TorMode.ON && tor.state != ArtiTorManager.TorState.RUNNING -> stringResource(R.string.tor_connecting_banner, tor.bootstrapPercent) + else -> null + } + warning?.let { + Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) { + Text(it, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 8517ec15..166cee76 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -14,9 +14,21 @@ class DataManager(private val context: Context) { companion object { private const val TAG = "DataManager" + private const val PREFS_NAME = "bitchat_prefs" + private const val BLOCKED_USERS_KEY = "blocked_users" + + /** + * Reads the current persisted block list for ingress paths that have a + * full Noise key but no live mesh peer/session. + */ + fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getStringSet(BLOCKED_USERS_KEY, emptySet()) + ?.contains(fingerprint) == true } - - private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val gson = Gson() // Channel-related maps that need to persist state @@ -200,13 +212,13 @@ class DataManager(private val context: Context) { @Synchronized fun loadBlockedUsers() { - val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet() _blockedUsers.clear() _blockedUsers.addAll(savedBlockedUsers) } fun saveBlockedUsers() { - prefs.edit().putStringSet("blocked_users", _blockedUsers).apply() + prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply() } fun addBlockedUser(fingerprint: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 63a5c0a7..b64a0e69 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -164,6 +164,7 @@ class GeohashViewModel( teleported ) val relayManager = NostrRelayManager.getInstance(getApplication()) + com.bitchat.android.services.bridge.MeshGatewayService.uplinkIfOffline(event, channel.geohash, liveLocationToken) relayManager.sendEventToGeohash( event, channel.geohash, diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 1269fe6b..68b17854 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -320,7 +320,10 @@ fun MessageInput( mentionPeerIdentities: Map = emptyMap(), recorderFactory: ((String?, String?) -> VoiceRecorder)? = null, activePublicTalker: String? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showBridgeControls: Boolean = false, + nearbyOnly: Boolean = false, + onNearbyOnlyChange: (Boolean) -> Unit = {} ) { val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme @@ -405,6 +408,25 @@ fun MessageInput( modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.Bottom ) { + if (showBridgeControls) { + IconToggleButton( + checked = nearbyOnly, + onCheckedChange = onNearbyOnlyChange, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (nearbyOnly) Icons.Filled.Bluetooth else Icons.Filled.Public, + contentDescription = if (nearbyOnly) { + stringResource(R.string.cd_nearby_only_on) + } else { + stringResource(R.string.cd_nearby_only_off) + }, + tint = if (nearbyOnly) Color(0xFFFF9500) else Color(0xFF00A7C4), + modifier = Modifier.size(18.dp) + ) + } + } + // MARK: - The pill. Field and action buttons are one visual object. Row( modifier = Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 2b4d6514..c7030c35 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -231,6 +231,21 @@ fun LocationChannelsSheet( verticalArrangement = Arrangement.spacedBy(0.dp) ) { // Mesh section: icon + title header, offline subtitle, then selection card + (selectedChannel as? ChannelID.Location)?.channel?.geohash?.let { cell -> + item(key = "share_channel") { + val shareTitle = stringResource(R.string.share_channel) + val link = com.bitchat.android.services.ChannelInvitation.link(cell) + val invitation = stringResource(R.string.channel_invitation, cell, link.orEmpty()) + androidx.compose.material3.TextButton(onClick = { + val link = com.bitchat.android.services.ChannelInvitation.link(cell) + if (link != null) context.startActivity(android.content.Intent.createChooser( + android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, invitation) + }, shareTitle)) + }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.share_channel)) } + } + } item(key = "mesh_card") { Column { SheetIconSectionHeader( diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 098f190b..0970909e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,31 +1,24 @@ package com.bitchat.android.ui -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID -import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.nostr.LocationNotesManager /** - * Location Notes button for MainHeader. - * Mesh-only with location authorized. Tor health tints the glyph with muted colours - * and a slow glow while connecting (via [rememberTorConnectionVisual]). + * Unified notices button for both mesh and geohash timelines. */ @Composable fun LocationNotesButton( @@ -33,39 +26,29 @@ fun LocationNotesButton( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - val context = LocalContext.current - val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val locationManager = remember { LocationChannelManager.getInstance(context) } - val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() - val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) + val boardPosts by viewModel.boardManager.posts.collectAsStateWithLifecycle() + val unseenScopes by viewModel.boardManager.unseenScopes.collectAsStateWithLifecycle() + val currentScope = when (val selected = selectedLocationChannel) { + is ChannelID.Location -> selected.channel.geohash + else -> "" + } + val hasNotices = boardPosts.any { it.geohash == currentScope } + val hasUnseen = currentScope in unseenScopes - val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED - val locationEnabled = locationPermissionGranted && locationServicesEnabled - - val notesManager = remember { LocationNotesManager.getInstance() } - val notes by notesManager.notes.collectAsStateWithLifecycle() - val notesCount = notes.size - - // Keep the header quiet until there is at least one nearby note worth opening. - if (selectedLocationChannel is ChannelID.Mesh && locationEnabled && notesCount > 0) { - val contentDescription = stringResource(R.string.cd_location_notes) - val torVisual = rememberTorConnectionVisual(normal = colorScheme.primary) - - Box( - modifier = modifier - .size(44.dp) - .clip(CircleShape) - .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), - contentAlignment = Alignment.Center - ) { - TorAwareHeaderIcon( - painter = painterResource(R.drawable.ic_spec_chat_bubbles), - tint = torVisual.tint, - isProgress = torVisual.isProgress, - contentDescription = contentDescription - ) - } + IconButton( + onClick = onClick, + modifier = modifier.size(48.dp) + ) { + Icon( + imageVector = if (hasUnseen) Icons.Filled.PushPin else Icons.Outlined.PushPin, + contentDescription = stringResource(R.string.cd_notices), + modifier = Modifier.size(19.dp), + tint = when { + hasUnseen -> MaterialTheme.colorScheme.primary + hasNotices -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index d5db98bb..fd41d17e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -50,6 +50,7 @@ class MediaSendingManager( // Track in-flight transfer progress: transferId -> messageId and reverse private val transferMessageMap = mutableMapOf() private val messageTransferMap = mutableMapOf() + private val privateTransferMessageIDs = mutableSetOf() private val pendingConsentLock = Any() private val _legacyPrivateMediaConsent = MutableStateFlow(null) val legacyPrivateMediaConsent: StateFlow = @@ -288,10 +289,17 @@ class MediaSendingManager( */ private suspend fun sendPrivateFile( toPeerID: String, - filePacket: BitchatFilePacket, + originalPacket: BitchatFilePacket, filePath: String, messageType: BitchatMessageType ) { + val filePacket = if (com.bitchat.android.model.PrivateMediaMessageIdentity.stableID( + meshService.myPeerID, meshService.myPeerID, originalPacket.fileName) != null) originalPacket + else when (originalPacket.mimeType) { + "image/jpeg" -> originalPacket.copy(fileName = "img_${UUID.randomUUID()}.jpg") + "audio/mp4", "audio/m4a" -> originalPacket.copy(fileName = "voice_${UUID.randomUUID()}.m4a") + else -> originalPacket + } val payload = withContext(mediaWorkDispatcher) { filePacket.encode() } ?: run { Log.e(TAG, "Failed to encode file packet for private send") @@ -454,7 +462,8 @@ class MediaSendingManager( pending.recipientMeshPeerID, pending.filePath, pending.messageType, - pending.transferId + pending.transferId, + pending.filePacket ) } @@ -605,15 +614,20 @@ class MediaSendingManager( recipientMeshPeerID: String, filePath: String, messageType: BitchatMessageType, - transferId: String + transferId: String, + filePacket: BitchatFilePacket ) { if (preparation.transfer.transferId != transferId) { Log.e(TAG, "Prepared private-media transfer ID changed; send aborted") return } + val stableID = if (preparation.transfer.wireMode == com.bitchat.android.mesh.PrivateMediaWireMode.ENCRYPTED_NOISE_0X20) { + com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(meshService.myPeerID, recipientMeshPeerID, filePacket.fileName) + } else null + val expectsReceipt = stableID != null && meshService.supportsPrivateMediaReceipts(recipientMeshPeerID) val msg = BitchatMessage( - id = UUID.randomUUID().toString().uppercase(), + id = stableID ?: UUID.randomUUID().toString().uppercase(), sender = state.getNicknameValue() ?: "me", content = filePath, type = messageType, @@ -638,7 +652,14 @@ class MediaSendingManager( ) return } + if (expectsReceipt && com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance() + ?.enqueue(msg.id, conversationID, recipientMeshPeerID, filePacket) != true) { + messageManager.updateMessageDeliveryStatus(msg.id, + com.bitchat.android.model.DeliveryStatus.Failed("Unable to save media for reliable delivery")) + return + } synchronized(transferMessageMap) { + privateTransferMessageIDs += msg.id transferMessageMap[transferId] = msg.id messageTransferMap[msg.id] = transferId } @@ -777,16 +798,24 @@ class MediaSendingManager( ) synchronized(transferMessageMap) { val msgIdRemoved = transferMessageMap.remove(evt.transferId) - if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + if (msgIdRemoved != null) { + messageTransferMap.remove(msgIdRemoved) + privateTransferMessageIDs.remove(msgIdRemoved) + } } } else if (evt.completed) { messageManager.updateMessageDeliveryStatus( msgId, - com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date()) + if (synchronized(transferMessageMap) { msgId in privateTransferMessageIDs }) { + com.bitchat.android.model.DeliveryStatus.Sent + } else com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date()) ) synchronized(transferMessageMap) { val msgIdRemoved = transferMessageMap.remove(evt.transferId) - if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + if (msgIdRemoved != null) { + messageTransferMap.remove(msgIdRemoved) + privateTransferMessageIDs.remove(msgIdRemoved) + } } } else { messageManager.updateMessageDeliveryStatus( diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index c6f7eb49..b52937f6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -160,6 +160,8 @@ class MeshDelegateHandler( override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { coroutineScope.launch { + com.bitchat.android.services.MessageRouter.tryGetInstance() + ?.onMessageAcknowledged(messageID, recipientPeerID) messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date())) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c..6411f467 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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 @@ -70,10 +73,13 @@ import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.services.bridge.BridgedParticipant import com.bitchat.android.util.hexEncodedString import kotlinx.coroutines.launch import kotlinx.coroutines.delay +private val TRUST_BADGE_SPACING = 4.dp +private val TRUST_BADGE_SIZE = 14.dp /** * Sheet components for ChatScreen @@ -96,6 +102,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() @@ -104,6 +112,7 @@ fun MeshPeerListSheet( val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle() val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle() val geohashPeopleCount = geohashPeople.size + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } val directPeerIdentityIDs = remember(peerDirect) { @@ -444,9 +453,31 @@ fun MeshPeerListSheet( onDismiss() } ) + + if (bridgeUiState.enabled && bridgeUiState.participants.isNotEmpty()) { + BridgedPeopleSection( + participants = bridgeUiState.participants, + colorScheme = colorScheme + ) + } } } } + + 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 +599,150 @@ fun MeshPeerListSheet( private val PeerRowIconSize = 22.dp private const val CONVERSATION_SEARCH_THRESHOLD = 8 +@Composable +private fun GroupSection( + groups: List, + myFingerprint: String, + unreadConversationIDs: Set, + 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 BridgedPeopleSection( + participants: List, + colorScheme: ColorScheme +) { + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = stringResource(R.string.across_bridge).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) + ) + participants.forEach { participant -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = null, + tint = Color(0xFF00A7C4), + modifier = Modifier.size(18.dp) + ) + Column { + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.via_mesh_bridge), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.55f) + ) + } + } + } + } +} + @Composable private fun ChannelRow( channel: String, @@ -684,6 +859,7 @@ fun PeopleSection( val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val vouchedFingerprints by viewModel.vouchedFingerprints.collectAsStateWithLifecycle() // Reactive favorite computation for all peers val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { @@ -808,6 +984,8 @@ fun PeopleSection( val isFavorite = peerFavoriteStates[peerID] ?: false val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false + val isVouched = !isVerified && + peerFingerprints[peerID]?.lowercase() in vouchedFingerprints // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below val noiseHex = noiseHexByPeerID[peerID] @@ -835,6 +1013,7 @@ fun PeopleSection( isFavorite = isFavorite, theyFavoritedUs = theyFavoritedUs, isVerified = isVerified, + isVouched = isVouched, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(peerID) }, @@ -865,6 +1044,7 @@ fun PeopleSection( val showHash = (baseNameCounts[bName] ?: 0) > 1 val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) + val isVouched = !isVerified && viewModel.isNoisePublicKeyVouched(fav.peerNoisePublicKey) val unreadCount = ( privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0 @@ -881,6 +1061,7 @@ fun PeopleSection( isFavorite = true, theyFavoritedUs = fav.theyFavoritedUs, isVerified = isVerified, + isVouched = isVouched, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, @@ -1467,6 +1648,7 @@ private fun PeerItem( isFavorite: Boolean, theyFavoritedUs: Boolean = false, isVerified: Boolean, + isVouched: Boolean, colorScheme: ColorScheme, viewModel: ChatViewModel, onItemClick: () -> Unit, @@ -1564,6 +1746,23 @@ private fun PeerItem( color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } + + if (isVerified) { + Icon( + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) + } else if (isVouched) { + Spacer(modifier = Modifier.width(TRUST_BADGE_SPACING)) + Icon( + imageVector = Icons.Outlined.VerifiedUser, + contentDescription = stringResource(R.string.fingerprint_status_vouched), + modifier = Modifier.size(TRUST_BADGE_SIZE), + tint = baseColor + ) + } } UnreadBadge( @@ -1687,6 +1886,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 +1915,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 +1925,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 +1942,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 +1952,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 +2046,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 +2122,7 @@ fun PrivateChatSheet( currentChannel = null, nickname = nickname, colorScheme = colorScheme, - showMediaButtons = true + showMediaButtons = !isGroupConversation ) } @@ -1910,6 +2147,7 @@ fun PrivateChatSheet( }, title = titleText ) { + if (!isGroupConversation) { ConversationHeaderAction( onClick = { viewModel.toggleFavorite(peerID) }, contentDescription = if (isFavorite) { @@ -1938,7 +2176,9 @@ fun PrivateChatSheet( ) } - if (isVerified) { + } + + if (!isGroupConversation && isVerified) { ConversationHeaderStatus { Icon( imageVector = Icons.Filled.Verified, @@ -1953,7 +2193,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) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshTopologySheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshTopologySheet.kt new file mode 100644 index 00000000..afb10960 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MeshTopologySheet.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.ui.debug.MeshTopologySection + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeshTopologySheet( + isPresented: Boolean, + onDismiss: () -> Unit, + meshService: MeshService, +) { + if (!isPresented) return + BitchatBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text("mesh topology", fontFamily = FontFamily.Monospace) + MeshTopologySection( + localPeerID = meshService.myPeerID, + blePeerIDs = meshService.getDirectBlePeerIDs(), + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 31e7d1af..fc685b53 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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) @@ -283,10 +299,11 @@ class MessageManager(private val state: ChatState) { private fun statusPriority(status: DeliveryStatus?): Int = when (status) { null -> 0 is DeliveryStatus.Sending -> 1 - is DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + is DeliveryStatus.Queued -> 2 + is DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 is DeliveryStatus.Failed -> 0 } diff --git a/app/src/main/java/com/bitchat/android/ui/NoticeComposerPolicy.kt b/app/src/main/java/com/bitchat/android/ui/NoticeComposerPolicy.kt new file mode 100644 index 00000000..fe6e7e75 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NoticeComposerPolicy.kt @@ -0,0 +1,11 @@ +package com.bitchat.android.ui + +internal object NoticeComposerPolicy { + private val finiteExpiryDays = listOf(1, 3, 7) + + fun expiryOptions(isGeo: Boolean): List = + if (isGeo) listOf(0) + finiteExpiryDays else finiteExpiryDays + + fun isPermanentRelayOnlyGeo(isGeo: Boolean, expiryDays: Int): Boolean = + isGeo && expiryDays == 0 +} diff --git a/app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt b/app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt new file mode 100644 index 00000000..4ae95a69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt @@ -0,0 +1,548 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.board.BoardPostPacket +import com.bitchat.android.board.NoticeSource +import com.bitchat.android.board.UnifiedNotice +import com.bitchat.android.board.UnifiedNotices +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.nostr.LocationNotesManager +import java.text.DateFormat +import java.util.Date + +private enum class NoticesTab { + GEO, + MESH +} + +@Composable +fun NoticesSheetPresenter( + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val locationManager = remember { LocationChannelManager.getInstance(context) } + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val selectedGeohash = (selectedChannel as? ChannelID.Location)?.channel?.geohash + val buildingGeohash = availableChannels + .firstOrNull { it.level == GeohashChannelLevel.BUILDING } + ?.geohash + + LaunchedEffect(Unit) { + locationManager.refreshChannels() + } + + NoticesSheet( + viewModel = viewModel, + geoGeohash = selectedGeohash ?: buildingGeohash, + startOnGeo = selectedGeohash != null, + onEnableLocation = { + locationManager.enableLocationServices() + locationManager.enableLocationChannels() + locationManager.refreshChannels() + }, + onDismiss = onDismiss + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun NoticesSheet( + viewModel: ChatViewModel, + geoGeohash: String?, + startOnGeo: Boolean, + onEnableLocation: () -> Unit, + onDismiss: () -> Unit +) { + val boardPosts by viewModel.boardManager.posts.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val notesManager = remember { LocationNotesManager.getInstance() } + val relayNotes by notesManager.notes.collectAsStateWithLifecycle() + var selectedTab by remember { + mutableStateOf(if (startOnGeo) NoticesTab.GEO else NoticesTab.MESH) + } + var draft by remember { mutableStateOf("") } + var urgent by remember { mutableStateOf(false) } + var expiryDays by remember { mutableIntStateOf(if (startOnGeo) 0 else 7) } + var sendFailed by remember { mutableStateOf(false) } + + val scope = if (selectedTab == NoticesTab.GEO) geoGeohash.orEmpty() else "" + val notices = remember(scope, boardPosts, relayNotes) { + UnifiedNotices.merge( + geohash = scope, + boardPosts = boardPosts, + relayNotes = if (scope.isEmpty()) emptyList() else relayNotes + ) + } + val geoCount = remember(geoGeohash, boardPosts, relayNotes) { + geoGeohash?.let { UnifiedNotices.merge(it, boardPosts, relayNotes).size } ?: 0 + } + val meshCount = remember(boardPosts) { + boardPosts.count { it.geohash.isEmpty() } + } + + LaunchedEffect(selectedTab, geoGeohash) { + if (selectedTab == NoticesTab.GEO && geoGeohash != null) { + if (notesManager.geohash.value == geoGeohash.lowercase() && + notesManager.state.value == LocationNotesManager.State.IDLE + ) { + notesManager.refresh() + } else { + notesManager.setGeohash(geoGeohash) + } + } else { + notesManager.cancel() + } + expiryDays = if (selectedTab == NoticesTab.GEO) 0 else 7 + urgent = false + viewModel.boardManager.markSeen(setOf(scope)) + } + LaunchedEffect(geoGeohash) { + viewModel.boardManager.markSeen( + buildSet { + add("") + geoGeohash?.let(::add) + } + ) + } + DisposableEffect(Unit) { + onDispose { notesManager.cancel() } + } + + BitchatBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + ) { + BitchatSheetTopBar( + onClose = onDismiss, + title = { + BitchatSheetTitle( + text = if (scope.isEmpty() && selectedTab == NoticesTab.GEO) { + stringResource(R.string.notices_title) + } else { + "${stringResource(R.string.notices_title)} @ #" + + if (scope.isEmpty()) "mesh" else scope + } + ) + } + ) + + PrimaryTabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == NoticesTab.GEO, + onClick = { selectedTab = NoticesTab.GEO }, + text = { + Text(stringResource(R.string.notices_tab_geo_count, geoCount)) + } + ) + Tab( + selected = selectedTab == NoticesTab.MESH, + onClick = { selectedTab = NoticesTab.MESH }, + text = { + Text(stringResource(R.string.notices_tab_mesh_count, meshCount)) + } + ) + } + + if (selectedTab == NoticesTab.GEO && geoGeohash == null) { + LocationUnavailable( + onEnableLocation = onEnableLocation, + modifier = Modifier.weight(1f) + ) + } else { + NoticesContent( + notices = notices, + scope = scope, + isGeo = selectedTab == NoticesTab.GEO, + showsSource = selectedTab == NoticesTab.GEO, + viewModel = viewModel, + notesManager = notesManager, + modifier = Modifier.weight(1f) + ) + } + + if (selectedTab == NoticesTab.MESH || geoGeohash != null) { + HorizontalDivider() + Composer( + draft = draft, + onDraftChange = { + if (it.toByteArray(Charsets.UTF_8).size <= 512) { + draft = it + sendFailed = false + } + }, + isGeo = selectedTab == NoticesTab.GEO, + urgent = urgent, + onUrgentChange = { urgent = it }, + expiryDays = expiryDays, + onExpiryChange = { expiryDays = it }, + enabled = true, + sendFailed = sendFailed, + onSend = { + if (NoticeComposerPolicy.isPermanentRelayOnlyGeo( + isGeo = selectedTab == NoticesTab.GEO, + expiryDays = expiryDays + ) + ) { + notesManager.send( + content = draft, + nickname = nickname, + expiresAt = null + ) + draft = "" + sendFailed = false + } else { + val sent = viewModel.boardManager.createPost( + content = draft, + geohash = scope, + nickname = nickname, + urgent = selectedTab == NoticesTab.MESH && urgent, + expiryDays = expiryDays + ) + if (sent) draft = "" else sendFailed = true + } + } + ) + } + } + } +} + +@Composable +private fun NoticesContent( + notices: List, + scope: String, + isGeo: Boolean, + showsSource: Boolean, + viewModel: ChatViewModel, + notesManager: LocationNotesManager, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + item(key = "description") { + Column { + Text( + text = if (isGeo) { + stringResource(R.string.location_notes_description) + } else { + stringResource(R.string.notices_description_mesh) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (scope.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text( + text = "#$scope", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + if (notices.isEmpty()) { + item(key = "empty") { + Column(modifier = Modifier.padding(vertical = 32.dp)) { + Text( + text = stringResource(R.string.location_notes_empty_title), + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_empty_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + items(notices, key = { it.id }) { notice -> + val ownBoard = notice.boardPost?.let(viewModel.boardManager::isOwnPost) == true + val ownRelay = notice.nostrNote?.let(notesManager::isOwnNote) == true + NoticeRow( + notice = notice, + showsSource = showsSource, + canDelete = ownBoard || ownRelay, + onDelete = { + notice.boardPost?.let(viewModel.boardManager::deletePost) + ?: notice.nostrNote?.let(notesManager::delete) + } + ) + } + } + } +} + +@Composable +private fun NoticeRow( + notice: UnifiedNotice, + showsSource: Boolean, + canDelete: Boolean, + onDelete: () -> Unit +) { + val boardAuthor = notice.boardPost?.let { post -> + post.authorNickname.trim().ifEmpty { + "anon#${post.authorSigningKey.takeLast(2).toByteArray().toHex()}" + } + } + val relayAuthor = notice.nostrNote?.displayName + val author = boardAuthor ?: relayAuthor ?: notice.nickname.ifEmpty { "anon" } + val time = remember(notice.createdAtMs) { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(Date(notice.createdAtMs)) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (notice.urgent) { + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.notices_urgent), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = "@$author", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + if (showsSource) { + Text( + text = if (notice.source == NoticeSource.MESH) { + stringResource(R.string.notices_source_mesh) + } else { + stringResource(R.string.notices_source_network) + }, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.primary + ) + } + if (canDelete) { + IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.notices_delete), + modifier = Modifier.size(18.dp) + ) + } + } + } + Text( + text = notice.content, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + text = notice.expiresAtMs?.let { + "$time · ${stringResource(R.string.notices_fades, relativeExpiry(it))}" + } ?: time, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun LocationUnavailable( + onEnableLocation: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp) + ) { + Text( + text = stringResource(R.string.notices_location_unavailable), + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.notices_location_unavailable_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(20.dp)) + Button(onClick = onEnableLocation) { + Text(stringResource(R.string.notices_enable_location)) + } + } + } +} + +@Composable +private fun Composer( + draft: String, + onDraftChange: (String) -> Unit, + isGeo: Boolean, + urgent: Boolean, + onUrgentChange: (Boolean) -> Unit, + expiryDays: Int, + onExpiryChange: (Int) -> Unit, + enabled: Boolean, + sendFailed: Boolean, + onSend: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (!isGeo) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.notices_urgent), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f) + ) + Switch(checked = urgent, onCheckedChange = onUrgentChange) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.notices_expiry), + style = MaterialTheme.typography.labelMedium + ) + NoticeComposerPolicy.expiryOptions(isGeo).forEach { days -> + val optionDescription = if (days == 0) { + stringResource(R.string.notices_permanent) + } else { + stringResource(R.string.notices_days, days) + } + FilterChip( + selected = expiryDays == days, + onClick = { onExpiryChange(days) }, + modifier = Modifier.semantics { + contentDescription = optionDescription + }, + label = { + Text(if (days == 0) "∞" else "${days}d") + } + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = draft, + onValueChange = onDraftChange, + enabled = enabled, + placeholder = { Text(stringResource(R.string.notices_placeholder)) }, + supportingText = if (sendFailed) { + { Text(stringResource(R.string.notices_send_failed)) } + } else { + null + }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + modifier = Modifier.weight(1f), + maxLines = 4 + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onSend, + enabled = enabled && draft.isNotBlank(), + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.notices_post) + ) + } + } + } +} + +private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + +private fun relativeExpiry(expiresAtMs: Long): String { + val remainingMs = (expiresAtMs - System.currentTimeMillis()).coerceAtLeast(0) + val hours = remainingMs / 3_600_000L + return if (hours >= 24) "${hours / 24}d" else "${hours}h" +} diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 2b77075d..2ad9d29c 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -68,6 +68,12 @@ class NotificationManager( private val liveManagers: MutableSet = Collections.newSetFromMap(WeakHashMap()) + fun clearAllForPrivacyChange(context: Context) { + synchronized(liveManagers) { liveManagers.toList() } + .forEach { it.clearAllNotifications(removeConversationShortcuts = true) } + NotificationManagerCompat.from(context).cancelAll() + } + /** * Synchronizes notification action receivers with every manager instance in this process. * Without this, an old in-memory MessagingStyle history could reappear on the next DM. @@ -198,8 +204,8 @@ class NotificationManager( val notification = PendingNotification( senderPeerID = conversationID, - senderNickname = senderNickname, - messageContent = messageContent, + senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name), + messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message), timestamp = System.currentTimeMillis() ) @@ -243,11 +249,9 @@ class NotificationManager( .setKey(senderPeerID) .build() val shortcutID = conversationShortcutID(senderPeerID) - publishConversationShortcut( - shortcutID = shortcutID, - person = person, - contentIntent = intent - ) + if (ClientPrivacyPreferences.showNotificationPreviews(context)) { + publishConversationShortcut(shortcutID = shortcutID, person = person, contentIntent = intent) + } // Build notification content val contentText = if (messageCount == 1) { @@ -523,8 +527,8 @@ class NotificationManager( val notification = GeohashNotification( geohash = geohash, - senderNickname = senderNickname, - messageContent = messageContent, + senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name), + messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message), timestamp = System.currentTimeMillis(), isMention = isMention, isFirstMessage = isFirstMessage, @@ -748,8 +752,8 @@ class NotificationManager( val meshMentionKey = "mesh_mentions" val notification = PendingNotification( senderPeerID = senderPeerID ?: meshMentionKey, - senderNickname = senderNickname, - messageContent = messageContent, + senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name), + messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message), timestamp = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt b/app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt new file mode 100644 index 00000000..0a08d531 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.ui + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R + +enum class PanicWipeState { IDLE, CONFIRM, ERASING, COMPLETE, FAILED } + +@Composable +fun PanicWipeDialog(state: PanicWipeState, onConfirm: () -> Unit, onDismiss: () -> Unit) { + if (state == PanicWipeState.IDLE) return + AlertDialog( + onDismissRequest = { if (state != PanicWipeState.ERASING) onDismiss() }, + title = { Text(stringResource(when (state) { + PanicWipeState.CONFIRM -> R.string.panic_confirm_title + PanicWipeState.ERASING -> R.string.panic_erasing_title + PanicWipeState.COMPLETE -> R.string.panic_complete_title + else -> R.string.panic_failed_title + })) }, + text = { + if (state == PanicWipeState.ERASING) CircularProgressIndicator() + else Text(stringResource(when (state) { + PanicWipeState.CONFIRM -> R.string.panic_confirm_body + PanicWipeState.COMPLETE -> R.string.panic_complete_body + else -> R.string.panic_failed_body + })) + }, + confirmButton = { + if (state != PanicWipeState.ERASING) TextButton(onClick = { + if (state == PanicWipeState.CONFIRM || state == PanicWipeState.FAILED) onConfirm() else onDismiss() + }) { Text(stringResource(when (state) { + PanicWipeState.CONFIRM -> R.string.panic_erase + PanicWipeState.FAILED -> R.string.panic_retry + else -> android.R.string.ok + })) } + }, + dismissButton = { + if (state == PanicWipeState.CONFIRM) TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + } + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 12678625..32dc9a81 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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>() } diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 71e59f0d..aa4a1e6f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.NoEncryption import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -37,6 +38,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -50,6 +52,8 @@ import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.ContactDirectory +private const val VOUCHED_SECONDARY_CONTENT_ALPHA = 0.8f + private data class SecurityStatusInfo( val text: String, val icon: ImageVector, @@ -68,6 +72,7 @@ fun SecurityVerificationSheet( val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val vouchedFingerprints by viewModel.vouchedFingerprints.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() val colorScheme = MaterialTheme.colorScheme @@ -107,8 +112,12 @@ fun SecurityVerificationSheet( activeMeshPeerID = activeMeshPeerID, peerSessionStates = peerSessionStates ) + val isVouched = !isVerified && + fingerprint?.lowercase() in vouchedFingerprints + val voucherNames = fingerprint?.let(viewModel::voucherNamesForFingerprint).orEmpty() val statusInfo = buildStatusInfo( isVerified = isVerified, + isVouched = isVouched, sessionState = sessionState, accent = accent ) @@ -136,6 +145,8 @@ fun SecurityVerificationSheet( SecurityVerificationActions( isVerified = isVerified, + isVouched = isVouched, + voucherNames = voucherNames, fingerprint = fingerprint, displayName = displayName, accent = accent, @@ -175,11 +186,13 @@ private fun SecurityVerificationHeader( @Composable private fun buildStatusInfo( isVerified: Boolean, + isVouched: Boolean, sessionState: String?, accent: Color ): SecurityStatusInfo { val text = when { isVerified -> stringResource(R.string.fingerprint_status_verified) + isVouched -> stringResource(R.string.fingerprint_status_vouched) sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) @@ -187,6 +200,7 @@ private fun buildStatusInfo( } val icon = when { isVerified -> Icons.Filled.Verified + isVouched -> Icons.Outlined.VerifiedUser sessionState == "handshaking" -> Icons.Outlined.Sync sessionState == "failed" -> Icons.Outlined.OutlinedWarning sessionState == "established" -> Icons.Filled.Lock @@ -194,6 +208,7 @@ private fun buildStatusInfo( } val tint = when { isVerified -> Color(0xFF32D74B) + isVouched -> accent sessionState == "failed" -> Color(0xFFFF3B30) sessionState == "handshaking" -> Color(0xFFFF9500) sessionState == "established" -> Color(0xFF32D74B) @@ -245,6 +260,8 @@ private fun SecurityStatusCard( @Composable private fun SecurityVerificationActions( isVerified: Boolean, + isVouched: Boolean, + voucherNames: List, fingerprint: String?, displayName: String, accent: Color, @@ -301,6 +318,34 @@ private fun SecurityVerificationActions( ) } } else { + if (isVouched) { + VerificationStatusRow( + icon = Icons.Outlined.VerifiedUser, + iconTint = accent, + text = stringResource(R.string.fingerprint_vouched_label), + textTint = accent + ) + Text( + text = pluralStringResource( + R.plurals.fingerprint_vouched_message, + voucherNames.size, + voucherNames.size + ), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = BitchatFontFamily), + color = accent.copy(alpha = VOUCHED_SECONDARY_CONTENT_ALPHA), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (voucherNames.isNotEmpty()) { + Text( + text = voucherNames.joinToString(), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = BitchatFontFamily), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } VerificationStatusRow( icon = Icons.Filled.Warning, iconTint = Color(0xFFFF9500), diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 8003508e..1250be47 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -13,9 +13,12 @@ import com.bitchat.android.services.VerificationService import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import java.security.MessageDigest import java.util.Date @@ -33,20 +36,48 @@ class VerificationHandler( private val notificationManager: NotificationManager, private val messageManager: MessageManager ) { + companion object { + private const val FINGERPRINT_NAME_PREFIX_LENGTH = 8 + private const val MINIMUM_EXPIRY_REFRESH_DELAY_MS = 1L + } + // Helper to get current mesh service (may change after panic clear) private val meshService: MeshService get() = getMeshService() private val _verifiedFingerprints = MutableStateFlow>(emptySet()) val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + private val _vouchedFingerprints = MutableStateFlow>(emptySet()) + val vouchedFingerprints: StateFlow> = _vouchedFingerprints.asStateFlow() private val pendingQRVerifications = ConcurrentHashMap() private val lastVerifyNonceByPeer = ConcurrentHashMap() private val lastInboundVerifyChallengeAt = ConcurrentHashMap() private val lastMutualToastAt = ConcurrentHashMap() + private var vouchExpiryRefreshJob: Job? = null + + init { + scope.launch { + SecureIdentityStateManager.changes.collect { + refreshIdentityState() + } + } + } fun loadVerifiedFingerprints() { + refreshIdentityState() + } + + private fun refreshIdentityState(nowMs: Long = System.currentTimeMillis()) { _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + _vouchedFingerprints.value = identityManager.getVouchedFingerprints(nowMs) + vouchExpiryRefreshJob?.cancel() + val nextExpiryMs = identityManager.nextVouchExpiryMs(nowMs) ?: return + val refreshDelayMs = (nextExpiryMs - nowMs).coerceAtLeast(MINIMUM_EXPIRY_REFRESH_DELAY_MS) + vouchExpiryRefreshJob = scope.launch { + delay(refreshDelayMs) + refreshIdentityState() + } } fun isPeerVerified(peerID: String): Boolean { @@ -60,6 +91,18 @@ class VerificationHandler( return _verifiedFingerprints.value.contains(fingerprint) } + fun isFingerprintVouched(fingerprint: String): Boolean = + _vouchedFingerprints.value.contains(fingerprint.lowercase()) + + fun vouchersForFingerprint(fingerprint: String): List = + identityManager.validVouchers(fingerprint) + + fun voucherNamesForFingerprint(fingerprint: String): List = + identityManager.validVouchers(fingerprint).map { record -> + identityManager.getCachedFingerprintNickname(record.voucherFingerprint) + ?: record.voucherFingerprint.take(FINGERPRINT_NAME_PREFIX_LENGTH) + } + fun unverifyFingerprint(peerID: String) { val fingerprint = meshService.getPeerFingerprint(peerID) ?: return identityManager.setVerifiedFingerprint(fingerprint, false) diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index b1822806..7f4fb793 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -107,6 +107,14 @@ object AppConstants { // Relay subscription validation const val SUBSCRIPTION_VALIDATION_INTERVAL_MS: Long = 30_000L + + // Client-side timestamp windows for inbound DMs (iOS TransportConfig parity). + // Inner rumor created_at is the sender's true send time; outer gift-wrap + // created_at is NIP-17-randomized into the past and may be older. + const val DM_SUBSCRIBE_LOOKBACK_SECONDS: Long = 86_400L // 24h + const val DM_MAX_CLOCK_SKEW_SECONDS: Long = 900L // 15min + // Outer gift-wrap age ceiling: 48h randomization + 15min skew. + const val DM_GIFT_WRAP_MAX_AGE_SECONDS: Long = 173_700L } object Tor { @@ -136,6 +144,7 @@ object AppConstants { const val OUTBOX_TICK_MS: Long = 2_000L const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours const val OUTBOX_MAX_PER_PEER: Int = 100 + const val MAX_COURIERS_PER_MESSAGE: Int = 3 val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L) } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 02bfb2d3..3dfde238 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -17,6 +17,7 @@ import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy import com.bitchat.android.mesh.FragmentingPacketSender import com.bitchat.android.mesh.MeshCore import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.MeshPingResult import com.bitchat.android.mesh.MeshTransport import com.bitchat.android.mesh.PeerInfo import com.bitchat.android.model.BitchatFilePacket @@ -166,6 +167,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor }, hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, + onDeliveryReceipt = { id, peer -> + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(id, peer) + com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(id, peer) + }, onAnnounceProcessed = { routed, _ -> publishControllerDebugSnapshot() routed.peerID?.let { pid -> @@ -240,6 +245,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor sendPacketToPeer(peerID, packet) } + override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean = + sendPacketToPeer(peerID, packet) + /** * Broadcasts routed packet to currently connected peers. */ @@ -1369,6 +1377,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendMessage(content, mentions, channel) } + override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) { + meshCore.sendNostrCarrier(payload, recipientPeerID) + } + + override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) { + meshCore.sendCourierEnvelope(payload, recipientPeerID) + } + + override fun sendPrekeyBundle(payload: ByteArray) { + meshCore.sendPrekeyBundle(payload) + } + /** * Sends a private encrypted message to a specific peer. * @@ -1401,6 +1421,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. @@ -1411,6 +1443,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendFileBroadcast(file) } + override fun sendBoardPayload(payload: ByteArray) { + meshCore.sendBoardPayload(payload) + } + + + /** * Sends a file privately to a specific peer. If no Noise session is established, * a handshake will be initiated and the send is deferred/aborted for now. @@ -1426,6 +1464,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendVoiceFrame(recipientPeerID, payload) } + override fun supportsPrivateMediaReceipts(peerID: String): Boolean = meshCore.supportsPrivateMediaReceipts(peerID) + override fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, @@ -1462,6 +1502,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendAnnouncementToPeer(peerID) } + override fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) { + meshCore.sendMeshPing(peerID, callback) + } + /** @return Mapping of peer IDs to nicknames. */ override fun getPeerNicknames(): Map = meshCore.getPeerNicknames() @@ -1521,6 +1565,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. */ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 772e48a5..51d3e6d4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + إعلانات + إعلانات + جغرافي · %1$d + mesh · %1$d + ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة. + دائم + يتلاشى %1$s + mesh + نت + الموقع غير متاح + تفعيل الموقع + 📌 إعلان عاجل من @%1$s: %2$s + 📌 %1$d إعلانات عاجلة جديدة — اضغط على الدبوس للعرض فتح قسم حول تحقّق من الملاحظات المتروكة هنا تُركت ملاحظة واحدة هنا — انقر للقراءة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index b6557fe2..6f062502 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + নোটিশ + নোটিশ + এলাকা · %1$d + mesh · %1$d + আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়। + স্থায়ী + %1$s মুছে যাবে + mesh + নেট + অবস্থান অনুপলব্ধ + লোকেশন চালু করুন + 📌 @%1$s-এর জরুরি নোটিশ: %2$s + 📌 %1$dটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন পরিচিতি খুলুন এখানে রাখা নোট আছে কি না দেখুন এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 75cc8927..808ec6c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -386,6 +386,20 @@ Verifiziert Du hast %1$s verifiziert verifiziert %1$s + + hinweise + hinweise + geo · %1$d + mesh · %1$d + hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst. + dauerhaft + verblasst %1$s + mesh + netz + standort nicht verfügbar + standort aktivieren + 📌 dringender hinweis von @%1$s: %2$s + 📌 %1$d neue dringende hinweise — tippe zum ansehen auf den pin Info öffnen nachsehen, ob hier notizen hinterlassen wurden 1 notiz hier hinterlassen — tippen zum lesen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3f08ead0..3e90144e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -385,6 +385,20 @@ Verificado Verificaste a %1$s verificado %1$s + + avisos + avisos + geo · %1$d + mesh · %1$d + fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días. + permanente + se desvanece %1$s + mesh + red + ubicación no disponible + activar ubicación + 📌 aviso urgente de @%1$s: %2$s + 📌 %1$d avisos urgentes nuevos — toca el pin para verlos Abrir Acerca de buscar notas dejadas aquí 1 nota dejada aquí — toca para leer diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1e092b64..324e6e26 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + اطلاعیه‌ها + اطلاعیه‌ها + ژئو · %1$d + مش · %1$d + اطلاعیه‌های کوتاه برای اطرافیان‌تان سنجاق کنید. گوشی به گوشی جابه‌جا می‌شوند، حتی آفلاین، و بعد از چند روز خودبه‌خود ناپدید می‌شوند. + دائمی + %1$s محو می‌شود + مش + نت + موقعیت در دسترس نیست + فعال‌سازی موقعیت + 📌 اطلاعیهٔ فوری از @%1$s: %2$s + 📌 %1$d اطلاعیهٔ فوری جدید — برای مشاهده روی سنجاق بزنید باز کردن درباره یادداشت‌های باقی‌مانده در اینجا را بررسی کنید ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index fa4aea0c..0d63bb71 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -383,6 +383,20 @@ Verified You verified %1$s verified %1$s + + mga paunawa + mga paunawa + geo · %1$d + mesh · %1$d + mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw. + permanente + maglalaho %1$s + mesh + net + walang lokasyon + i-on ang lokasyon + 📌 agarang paunawa mula kay @%1$s: %2$s + 📌 %1$d bagong agarang paunawa — i-tap ang pin para makita Buksan ang Tungkol tingnan kung may mga note na naiwan dito 1 note ang naiwan dito — i-tap para basahin diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a884d35e..bf30d007 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -397,6 +397,20 @@ Vérifié Vous avez vérifié %1$s vérifié %1$s + + annonces + annonces + géo · %1$d + mesh · %1$d + épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d\'elles-mêmes après quelques jours. + permanent + s\'efface %1$s + mesh + net + localisation indisponible + activer la localisation + 📌 annonce urgente de @%1$s : %2$s + 📌 %1$d nouvelles annonces urgentes — touche l\'épingle pour voir Ouvrir À propos vérifier s\'il y a des notes laissées ici 1 note laissée ici — appuyez pour lire diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index f742042e..2800a13b 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -392,4 +392,21 @@ שפת האפליקציה ברירת המחדל של המערכת בחירת שפה + מודעות + מודעות + אזור · %1$d + mesh · %1$d + הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים. + קבוע + דוהה %1$s + mesh + רשת + המיקום לא זמין + הפעל מיקום + 📌 מודעה דחופה מאת @%1$s: %2$s + 📌 %1$d מודעות דחופות חדשות — הקש על הנעץ לצפייה + פתיחת אודות + בדיקה אם הושארו כאן פתקים + פתק אחד הושאר כאן — הקש לקריאה + %d פתקים הושארו כאן — הקש לקריאה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 4cb18e38..3f9a1051 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + सूचनाएँ + सूचनाएँ + क्षेत्र · %1$d + mesh · %1$d + आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं। + स्थायी + %1$s मिट जाएगा + mesh + नेट + लोकेशन उपलब्ध नहीं + लोकेशन सक्षम करें + 📌 @%1$s की ज़रूरी सूचना: %2$s + 📌 %1$d नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें परिचय खोलें देखें कि यहाँ नोट छोड़े गए हैं या नहीं यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 02c86a68..c8832cec 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + pengumuman + pengumuman + area · %1$d + mesh · %1$d + sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari. + permanen + memudar %1$s + mesh + net + lokasi tidak tersedia + aktifkan lokasi + 📌 pengumuman mendesak dari @%1$s: %2$s + 📌 %1$d pengumuman mendesak baru — ketuk pin untuk melihat Buka Tentang periksa catatan yang ditinggalkan di sini 1 catatan ditinggalkan di sini — ketuk untuk membaca diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index af59fad3..50244ec1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -412,6 +412,20 @@ Verificato Hai verificato %1$s verificato %1$s + + avvisi + avvisi + geo · %1$d + mesh · %1$d + appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno. + permanente + svanisce %1$s + mesh + rete + posizione non disponibile + attiva posizione + 📌 avviso urgente da @%1$s: %2$s + 📌 %1$d nuovi avvisi urgenti — tocca la puntina per vederli Apri Informazioni controlla se ci sono note lasciate qui 1 nota lasciata qui — tocca per leggere diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 17841dcc..6c95e804 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -385,6 +385,20 @@ 検証済み %1$s を検証しました %1$s を検証しました + + お知らせ + お知らせ + エリア · %1$d + mesh · %1$d + 近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。 + 無期限 + %1$sに消えます + mesh + ネット + 位置情報を取得できません + 位置情報を有効化 + 📌 @%1$sからの緊急のお知らせ: %2$s + 📌 新しい緊急のお知らせが%1$d件 — ピンをタップして表示 このアプリについてを開く ここに残されたメモを確認 ここに1件のメモがあります — タップして読む diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 030165d6..f5780198 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + 공지 + 공지 + 지역 · %1$d + mesh · %1$d + 주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다. + 영구 + %1$s 사라짐 + mesh + 넷 + 위치 사용 불가 + 위치 활성화 + 📌 @%1$s님의 긴급 공지: %2$s + 📌 새 긴급 공지 %1$d개 — 핀을 탭하여 확인 정보 열기 여기 남겨진 쪽지 확인 여기 남겨진 쪽지 1개 — 탭하여 읽기 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 79bf5b1c..3ff3b858 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -433,4 +433,21 @@ Bahasa aplikasi Lalai sistem Pilih bahasa + pengumuman + pengumuman + kawasan · %1$d + mesh · %1$d + semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari. + kekal + pudar %1$s + mesh + net + lokasi tidak tersedia + aktifkan lokasi + 📌 pengumuman segera daripada @%1$s: %2$s + 📌 %1$d pengumuman segera baharu — ketik pin untuk melihat + Buka Perihal + semak nota yang ditinggalkan di sini + 1 nota ditinggalkan di sini — ketik untuk baca + %d nota ditinggalkan di sini — ketik untuk baca diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 59d52853..86247cdb 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -383,6 +383,20 @@ Verified You verified %1$s verified %1$s + + सूचनाहरू + सूचनाहरू + क्षेत्र · %1$d + mesh · %1$d + वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्। + स्थायी + %1$s मेटिन्छ + mesh + नेट + स्थान उपलब्ध छैन + स्थान सक्षम गर + 📌 @%1$sको जरुरी सूचना: %2$s + 📌 %1$d नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर परिचय खोल्नुहोस् यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1e46f196..a3dd971c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -410,6 +410,20 @@ Verified You verified %1$s verified %1$s + + mededelingen + mededelingen + geo · %1$d + mesh · %1$d + prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen. + permanent + vervaagt %1$s + mesh + net + locatie niet beschikbaar + locatie inschakelen + 📌 dringende mededeling van @%1$s: %2$s + 📌 %1$d nieuwe dringende mededelingen — tik op de pin om te bekijken Info openen kijk of hier notities zijn achtergelaten 1 notitie hier achtergelaten — tik om te lezen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3ec84dd4..204b9a7b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -398,4 +398,21 @@ Język aplikacji Domyślny systemu Wybierz język + ogłoszenia + ogłoszenia + geo · %1$d + mesh · %1$d + przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach. + na stałe + zniknie %1$s + mesh + sieć + lokalizacja niedostępna + włącz lokalizację + 📌 pilne ogłoszenie od @%1$s: %2$s + 📌 %1$d nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć + Otwórz informacje + sprawdź, czy zostawiono tutaj notatki + 1 notatka zostawiona tutaj — stuknij, aby przeczytać + %d notatek zostawionych tutaj — stuknij, aby przeczytać diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 42340857..b8977dbe 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -385,6 +385,19 @@ Verificado Você verificou %1$s verificou %1$s + avisos + avisos + geo · %1$d + mesh · %1$d + fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias. + permanente + desaparece %1$s + mesh + rede + localização indisponível + habilitar localização + 📌 aviso urgente de @%1$s: %2$s + 📌 %1$d avisos urgentes novos — toque no pin para ver ver se há notas deixadas aqui 1 nota deixada aqui — toque para ler %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0e01bb07..f7a7ef51 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -385,6 +385,20 @@ Verificado Você verificou %1$s verificou %1$s + + avisos + avisos + geo · %1$d + mesh · %1$d + afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias. + permanente + desvanece %1$s + mesh + rede + localização indisponível + ativar localização + 📌 aviso urgente de @%1$s: %2$s + 📌 %1$d novos avisos urgentes — toca no pin para ver Abrir Sobre ver se há notas deixadas aqui 1 nota deixada aqui — toque para ler diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 02b1175d..7a289d57 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -372,6 +372,20 @@ Проверено Вы проверили %1$s проверен %1$s + + объявления + объявления + гео · %1$d + mesh · %1$d + закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней. + навсегда + исчезнет %1$s + mesh + сеть + локация недоступна + включить локацию + 📌 срочное объявление от @%1$s: %2$s + 📌 %1$d новых срочных объявлений — нажми на булавку, чтобы посмотреть Открыть раздел «О приложении» проверить, есть ли здесь заметки здесь оставлена 1 заметка — нажмите, чтобы прочитать diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b958faad..4b2dd58d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -370,6 +370,20 @@ Verified You verified %1$s verified %1$s + + anslag + anslag + geo · %1$d + mesh · %1$d + nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar. + permanent + tonar bort %1$s + mesh + nät + plats ej tillgänglig + aktivera plats + 📌 brådskande anslag från @%1$s: %2$s + 📌 %1$d nya brådskande anslag — tryck på nålen för att visa Öppna Om kolla om anteckningar lämnats här 1 anteckning lämnad här — tryck för att läsa diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 243f2112..6cf00d63 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -390,4 +390,21 @@ பயன்பாட்டு மொழி கணினி இயல்புநிலை மொழியைத் தேர்ந்தெடுக்கவும் + அறிவிப்புகள் + அறிவிப்புகள் + பகுதி · %1$d + mesh · %1$d + அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும். + நிரந்தரம் + %1$s மறையும் + mesh + நெட் + இடம் கிடைக்கவில்லை + இடத்தை இயக்கு + 📌 @%1$s இன் அவசர அறிவிப்பு: %2$s + 📌 %1$d புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும் + அறிமுகத்தைத் திற + இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும் + இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும் + இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index b21049c1..c0edbe39 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + ประกาศ + ประกาศ + พื้นที่ · %1$d + mesh · %1$d + ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน + ถาวร + เลือนหาย %1$s + mesh + เน็ต + ไม่มีข้อมูลตำแหน่ง + เปิดใช้งานตำแหน่ง + 📌 ประกาศด่วนจาก @%1$s: %2$s + 📌 ประกาศด่วนใหม่ %1$d รายการ — แตะหมุดเพื่อดู เปิดเกี่ยวกับ ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 251d6a46..c1ec19b9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -370,6 +370,20 @@ Verified You verified %1$s verified %1$s + + duyurular + duyurular + bölge · %1$d + mesh · %1$d + çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur. + kalıcı + %1$s kaybolur + mesh + ağ + konum kullanılamıyor + konumu etkinleştir + 📌 @%1$s kişisinden acil duyuru: %2$s + 📌 %1$d yeni acil duyuru — görmek için raptiyeye dokun Hakkında’yı aç buraya bırakılan notlara bak buraya 1 not bırakıldı — okumak için dokun diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f248ff71..e5ec5bbe 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -400,4 +400,21 @@ Мова застосунку Системна мова Вибрати мову + оголошення + оголошення + гео · %1$d + mesh · %1$d + закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів. + назавжди + зникне %1$s + mesh + мережа + локація недоступна + увімкнути локацію + 📌 термінове оголошення від @%1$s: %2$s + 📌 %1$d нових термінових оголошень — натисни на шпильку, щоб переглянути + Відкрити розділ «Про застосунок» + перевірити, чи залишено тут нотатки + тут залишено 1 нотатку — торкніться, щоб прочитати + тут залишено %d нотаток — торкніться, щоб прочитати diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 785338fb..be6c71da 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + اعلانات + اعلانات + علاقہ · %1$d + mesh · %1$d + آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔ + مستقل + %1$s مٹ جائے گا + mesh + نیٹ + لوکیشن دستیاب نہیں + لوکیشن فعال کریں + 📌 @%1$s کا فوری اعلان: %2$s + 📌 %1$d نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں تعارف کھولیں دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b97c3fc5..73e648d9 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + thông báo + thông báo + khu vực · %1$d + mesh · %1$d + ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày. + vĩnh viễn + mờ dần %1$s + mesh + mạng + không có dữ liệu vị trí + bật vị trí + 📌 thông báo khẩn từ @%1$s: %2$s + 📌 %1$d thông báo khẩn mới — chạm vào ghim để xem Mở phần Giới thiệu kiểm tra ghi chú để lại ở đây có 1 ghi chú để lại ở đây — chạm để đọc diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fca6b07b..9cb59954 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -388,4 +388,20 @@ 应用语言 跟随系统 选择语言 + 公告 + 公告 + 区域 · %1$d + mesh · %1$d + 为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。 + 永久 + %1$s消失 + mesh + 网络 + 位置不可用 + 启用位置 + 📌 来自 @%1$s 的紧急公告:%2$s + 📌 %1$d 条新紧急公告 — 点按图钉查看 + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f427dbaf..e83e90c0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -389,4 +389,20 @@ 應用程式語言 跟隨系統 選擇語言 + 公告 + 公告 + 區域 · %1$d + mesh · %1$d + 為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。 + 永久 + %1$s消失 + mesh + 網路 + 位置不可用 + 啟用位置 + 📌 來自 @%1$s 的緊急公告:%2$s + 📌 %1$d 則新緊急公告 — 點按圖釘查看 + 查看這裡留下的留言 + 這裡留有 1 則留言 — 點按閱讀 + 這裡留有 %d 則留言 — 點按閱讀 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 35f7477a..00c69754 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -396,6 +396,20 @@ 已验证 你已验证 %1$s 已验证 %1$s + + 公告 + 公告 + 区域 · %1$d + mesh · %1$d + 为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。 + 永久 + %1$s消失 + mesh + 网络 + 位置不可用 + 启用位置 + 📌 来自 @%1$s 的紧急公告:%2$s + 📌 %1$d 条新紧急公告 — 点按图钉查看 打开“关于” 查看这里留下的留言 这里留有 1 条留言 — 点按阅读 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46d596f..7cc85a7d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,12 @@ Back People Channels + Groups + Private group + %1$d members + Creator + Encrypted to group members + Private group chat Online Users No one connected Triple tap to clear all data @@ -178,7 +184,7 @@ No servers, accounts, or data collection. Ephemeral Identity A new peer ID is generated regularly. - Triple-tap the logo to instantly clear all data. + Triple-tap the logo to review and confirm erasing local app data. Tor Network @@ -447,6 +453,27 @@ Be the first to add one for this spot. Dismiss Add a note for this place + Notices + notices + geo · %1$d + mesh · %1$d + pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days. + urgent + add a notice + Post notice + Delete notice + expires + %1$d days + permanent + fades %1$s + mesh + net + location unavailable + enable location to view persistent notices near you. + enable location + could not sign and send this notice + 📌 urgent notice from @%1$s: %2$s + 📌 %1$d new urgent notices — tap the pin to view Debug Tools @@ -492,11 +519,17 @@ Handshake pending Open a private chat to view fingerprints Encrypted & verified + Encrypted & vouched Encrypted Handshaking Handshake failed Not encrypted Verified + Vouched + + Vouched for by %1$d person you verified + Vouched for by %1$d people you verified + You have verified this person\'s identity. Not verified Compare these fingerprints with %1$s using a secure channel. @@ -578,6 +611,13 @@ Drag to spin · Pinch to zoom · Tap to focus Select Type a message… + mesh bridge + share public nearby messages through relays and carry encrypted offline messages for others + across the bridge + via mesh bridge + Mesh bridge active + Nearby only: messages stay within radio range + Bridged: messages also reach people across the bridge @%1$s Mention %1$d / %2$d @@ -654,4 +694,29 @@ App language System default Select language + New message + Notification previews + Show sender names and message text in notifications. Hidden by default. + Custom relays + Add relays for your direct messages. Built-in relays stay available. + Relay URL + Add relay + Remove + Enter a valid wss URL without credentials or query parameters. Up to 20 custom relays are supported. + Share channel invitation + Join #%1$s on bitchat: %2$s — get the app at https://bitchat.free + Bluetooth is unavailable. Nearby Bluetooth messaging is paused. + Tor could not connect. Internet messages are paused. + Connecting to Tor (%1$d%%). Internet messages are waiting. + Erase local bitchat data? + This removes conversations, media, keys, favorites, groups, and custom relays from this app. Copies held by other people or relays remain. This cannot be undone. + Erasing local data… + Local data erased + A new identity is ready. Copies on other devices and relays were not erased. + Erase incomplete + Some local data could not be erased. Messaging remains paused. Retry to finish. + Erase data + Retry erase + Share internet with the mesh + Carry signed public channel messages between nearby peers and relays. Off by default. diff --git a/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt b/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt new file mode 100644 index 00000000..9de2fd1c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt @@ -0,0 +1,190 @@ +package com.bitchat.android.board + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.security.SecureRandom + +class BoardManagerTest { + private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { it.toByte() }, 0) + private val publicKey = privateKey.generatePublicKey().encoded + private val geoIdentity = BoardSigningIdentity.fromEd25519Seed(ByteArray(32) { (it + 64).toByte() }) + + @Test + fun `create and delete emit iOS-compatible signed wire payloads`() = runTest { + val mesh = mock() + val notes = mock() + whenever(mesh.getSigningPublicKey()).thenReturn(publicKey) + whenever(mesh.signData(any())).thenAnswer { invocation -> + sign(invocation.getArgument(0)) + } + val manager = BoardManager( + store = BoardStore(nowMs = { NOW }), + scope = backgroundScope, + meshProvider = { mesh }, + geoIdentityProvider = { geoIdentity }, + notesManager = notes, + nowMs = { NOW }, + random = SecureRandom(byteArrayOf(7)) + ) + + assertTrue( + manager.createPost( + content = " water at gate two ", + geohash = "U33DC", + nickname = "alice", + urgent = false, + expiryDays = 3 + ) + ) + val payloads = argumentCaptor() + verify(mesh).sendBoardPayload(payloads.capture()) + val post = (BoardWireCodec.decode(payloads.firstValue) as BoardWire.Post).packet + assertEquals("water at gate two", post.content) + assertEquals("u33dc", post.geohash) + assertEquals(NOW + 3uL * DAY_MS, post.expiresAt) + assertTrue(geoIdentity.publicKey.contentEquals(post.authorSigningKey)) + assertTrue(post.verifySignature()) + verify(mesh, never()).signData(any()) + + val reloadedManager = BoardManager( + store = BoardStore(nowMs = { NOW }), + scope = backgroundScope, + meshProvider = { mesh }, + geoIdentityProvider = { geoIdentity }, + notesManager = notes, + nowMs = { NOW } + ) + assertTrue(reloadedManager.deletePost(post)) + verify(mesh, times(2)).sendBoardPayload(payloads.capture()) + val tombstone = + (BoardWireCodec.decode(payloads.allValues.last()) as BoardWire.Tombstone).packet + assertTrue(tombstone.postID.contentEquals(post.postID)) + assertTrue(tombstone.verifySignature()) + } + + @Test + fun `mesh board keeps the established mesh signing identity`() = runTest { + val mesh = mock() + whenever(mesh.getSigningPublicKey()).thenReturn(publicKey) + whenever(mesh.signData(any())).thenAnswer { invocation -> + sign(invocation.getArgument(0)) + } + val manager = BoardManager( + store = BoardStore(nowMs = { NOW }), + scope = backgroundScope, + meshProvider = { mesh }, + geoIdentityProvider = { geoIdentity }, + notesManager = mock(), + nowMs = { NOW } + ) + + assertTrue(manager.createPost("mesh notice", "", "alice", false, 1)) + + val payload = argumentCaptor() + verify(mesh).sendBoardPayload(payload.capture()) + val post = (BoardWireCodec.decode(payload.firstValue) as BoardWire.Post).packet + assertTrue(publicKey.contentEquals(post.authorSigningKey)) + assertTrue(post.verifySignature()) + verify(mesh).signData(any()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `remote urgent arrivals badge their scope and collapse into an alert`() = runTest { + val mesh = mock() + whenever(mesh.getSigningPublicKey()).thenReturn(ByteArray(32) { 9 }) + val store = BoardStore(nowMs = { NOW }) + val alerts = mutableListOf>>() + val manager = BoardManager( + store = store, + scope = backgroundScope, + meshProvider = { mesh }, + notesManager = mock(), + nowMs = { NOW }, + onUrgentPosts = { geohash, posts -> alerts += geohash to posts } + ) + runCurrent() + val post = signedPost(urgent = true) + val wire = BoardWire.Post(post) + + assertEquals( + BoardIngestResult.ACCEPTED, + store.ingest( + wire = wire, + packet = BitchatPacket( + type = MessageType.BOARD_POST.value, + senderID = ByteArray(8) { 1 }, + timestamp = NOW, + payload = BoardWireCodec.encode(wire), + ttl = 7u + ), + source = BoardIngestSource.REMOTE + ) + ) + runCurrent() + assertTrue("u33dc" in manager.unseenScopes.value) + advanceTimeBy(4_000) + runCurrent() + assertEquals(listOf("u33dc"), alerts.map { it.first }) + assertEquals(listOf(post), alerts.single().second) + + manager.markSeen(setOf("u33dc")) + assertFalse("u33dc" in manager.unseenScopes.value) + } + + private fun signedPost(urgent: Boolean): BoardPostPacket { + val postID = ByteArray(16) { 4 } + val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u + val signingBytes = BoardPostPacket.signingBytes( + postID, + "u33dc", + "road closed", + publicKey, + "alice", + NOW, + NOW + DAY_MS, + flags + ) + return BoardPostPacket( + postID, + "u33dc", + "road closed", + publicKey, + "alice", + NOW, + NOW + DAY_MS, + flags, + sign(signingBytes) + ) + } + + private fun sign(message: ByteArray): ByteArray = Ed25519Signer().run { + init(true, privateKey) + update(message, 0, message.size) + generateSignature() + } + + private companion object { + const val NOW: ULong = 1_700_000_000_000uL + const val DAY_MS: ULong = 86_400_000uL + } +} diff --git a/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt b/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt new file mode 100644 index 00000000..99762a5f --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt @@ -0,0 +1,217 @@ +package com.bitchat.android.board + +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BoardPacketsTest { + private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { it.toByte() }, 0) + private val publicKey = privateKey.generatePublicKey().encoded + + @Test + fun `post round trips and verifies`() { + val post = signedPost(geohash = "u33dc1", content = "water at gate 2", urgent = true) + val encoded = BoardWireCodec.encode(BoardWire.Post(post)) + val decoded = BoardWireCodec.decode(encoded) as BoardWire.Post + + assertEquals(post, decoded.packet) + assertTrue(decoded.verifySignature()) + assertTrue(BoardWireCodec.urgentFlag(encoded)) + } + + @Test + fun `mesh-local post and tombstone round trip`() { + val post = signedPost(geohash = "", content = "mesh notice", urgent = false) + val deletedAt = 1_700_000_100_000uL + val tombstoneBytes = BoardTombstonePacket.signingBytes(post.postID, deletedAt) + val tombstone = BoardTombstonePacket( + postID = post.postID, + authorSigningKey = publicKey, + deletedAt = deletedAt, + signature = sign(tombstoneBytes) + ) + + val decoded = BoardWireCodec.decode( + BoardWireCodec.encode(BoardWire.Tombstone(tombstone)) + ) as BoardWire.Tombstone + + assertEquals(tombstone, decoded.packet) + assertTrue(decoded.verifySignature()) + assertFalse(BoardWireCodec.urgentFlag(BoardWireCodec.encode(BoardWire.Post(post)))) + } + + @Test + fun `tampered content fails signature verification`() { + val encoded = BoardWireCodec.encode( + BoardWire.Post(signedPost(content = "original")) + ) + val decoded = BoardWireCodec.decode(encoded) as BoardWire.Post + val tampered = BoardPostPacket( + postID = decoded.packet.postID, + geohash = decoded.packet.geohash, + content = "changed", + authorSigningKey = decoded.packet.authorSigningKey, + authorNickname = decoded.packet.authorNickname, + createdAt = decoded.packet.createdAt, + expiresAt = decoded.packet.expiresAt, + flags = decoded.packet.flags, + signature = decoded.packet.signature + ) + + assertFalse(tampered.verifySignature()) + } + + @Test + fun `decoder rejects invalid field sizes lifetime and geohash`() { + assertNull(BoardWireCodec.decode(replaceTlv( + BoardWireCodec.encode(BoardWire.Post(signedPost())), + type = 0x02, + value = ByteArray(15) + ))) + assertNull(BoardWireCodec.decode(replaceTlv( + BoardWireCodec.encode(BoardWire.Post(signedPost())), + type = 0x03, + value = "u33dio".toByteArray() + ))) + + val tooLong = signedPost( + createdAt = 1_700_000_000_000uL, + expiresAt = 1_700_000_000_000uL + BoardWireConstants.MAX_LIFETIME_MS + 1uL + ) + assertNull(BoardWireCodec.decode(BoardWireCodec.encode(BoardWire.Post(tooLong)))) + } + + @Test + fun `unknown TLVs are ignored`() { + val post = signedPost() + val encoded = BoardWireCodec.encode(BoardWire.Post(post)) + val unknown = byteArrayOf(0x7F, 0x00, 0x03, 0x01, 0x02, 0x03) + + val decoded = BoardWireCodec.decode(encoded + unknown) as BoardWire.Post + + assertEquals(post, decoded.packet) + assertTrue(decoded.verifySignature()) + } + + @Test + fun `canonical signing bytes use context and big-endian length prefixes`() { + val post = signedPost( + postID = ByteArray(16) { (it + 1).toByte() }, + geohash = "u4", + content = "hi", + nickname = "n", + createdAt = 0x0102030405060708uL, + expiresAt = 0x1112131415161718uL, + urgent = true + ) + + val bytes = post.signingBytes + assertEquals(BoardWireConstants.POST_SIGNING_CONTEXT.length, bytes[0].toInt()) + assertArrayEquals( + BoardWireConstants.POST_SIGNING_CONTEXT.toByteArray(), + bytes.copyOfRange(1, 1 + BoardWireConstants.POST_SIGNING_CONTEXT.length) + ) + assertTrue(post.verifySignature()) + } + + @Test + fun `transport sender is scoped to embedded author identity`() { + val wire = BoardWire.Post(signedPost()) + val sameAuthor = BoardWire.Post(signedPost(content = "another notice")) + val otherIdentity = BoardSigningIdentity.fromEd25519Seed(ByteArray(32) { (it + 7).toByte() }) + val otherPost = BoardPostPacket( + postID = ByteArray(16) { 9 }, + geohash = "u33dc1", + content = "other author", + authorSigningKey = otherIdentity.publicKey, + authorNickname = "bob", + createdAt = 1_700_000_000_000uL, + expiresAt = 1_700_086_400_000uL, + flags = 0u, + signature = ByteArray(64) + ) + + assertEquals(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH, wire.transportSenderID().size) + assertArrayEquals(wire.transportSenderID(), sameAuthor.transportSenderID()) + assertFalse(wire.transportSenderID().contentEquals(BoardWire.Post(otherPost).transportSenderID())) + } + + @Test + fun `geo board identity is deterministic and domain separated from Nostr key`() { + val nostrSecret = ByteArray(32) { (it + 11).toByte() } + val secretHex = nostrSecret.joinToString("") { "%02x".format(it) } + val first = BoardSigningIdentity.fromNostrPrivateKeyHex(secretHex)!! + val second = BoardSigningIdentity.fromNostrPrivateKeyHex(secretHex)!! + + assertArrayEquals(first.publicKey, second.publicKey) + assertFalse(first.publicKey.contentEquals(nostrSecret)) + val message = "ios-compatible-board-payload".toByteArray() + assertTrue(BoardWireCodec.verify(first.sign(message)!!, message, first.publicKey)) + } + + private fun signedPost( + postID: ByteArray = ByteArray(16) { (it + 1).toByte() }, + geohash: String = "u33dc1", + content: String = "notice", + nickname: String = "alice", + createdAt: ULong = 1_700_000_000_000uL, + expiresAt: ULong = createdAt + 86_400_000uL, + urgent: Boolean = false + ): BoardPostPacket { + val flags = if (urgent) BoardPostPacket.URGENT_FLAG else 0u.toUByte() + val signingBytes = BoardPostPacket.signingBytes( + postID = postID, + geohash = geohash, + content = content, + authorSigningKey = publicKey, + authorNickname = nickname, + createdAt = createdAt, + expiresAt = expiresAt, + flags = flags + ) + return BoardPostPacket( + postID = postID, + geohash = geohash, + content = content, + authorSigningKey = publicKey, + authorNickname = nickname, + createdAt = createdAt, + expiresAt = expiresAt, + flags = flags, + signature = sign(signingBytes) + ) + } + + private fun sign(message: ByteArray): ByteArray = Ed25519Signer().run { + init(true, privateKey) + update(message, 0, message.size) + generateSignature() + } + + private fun replaceTlv(encoded: ByteArray, type: Int, value: ByteArray): ByteArray { + val output = ArrayList() + var offset = 0 + while (offset + 3 <= encoded.size) { + val currentType = encoded[offset].toInt() and 0xFF + val length = + ((encoded[offset + 1].toInt() and 0xFF) shl 8) or + (encoded[offset + 2].toInt() and 0xFF) + val end = offset + 3 + length + if (currentType == type) { + output += type.toByte() + output += ((value.size ushr 8) and 0xFF).toByte() + output += (value.size and 0xFF).toByte() + output += value.toList() + } else { + output += encoded.copyOfRange(offset, end).toList() + } + offset = end + } + return output.toByteArray() + } +} diff --git a/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt b/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt new file mode 100644 index 00000000..bda4aa41 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt @@ -0,0 +1,239 @@ +package com.bitchat.android.board + +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class BoardStoreTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private var now = 1_700_000_000_000uL + private val author = Key(ByteArray(32) { it.toByte() }) + private val attacker = Key(ByteArray(32) { (it + 32).toByte() }) + + @Test + fun `expired and future-dated posts are rejected`() { + val store = BoardStore(nowMs = { now }) + + assertEquals( + BoardIngestResult.REJECTED, + store.ingestPost(signedPost(createdAt = now - 100uL, expiresAt = now)) + ) + assertEquals( + BoardIngestResult.REJECTED, + store.ingestPost( + signedPost( + createdAt = now + BoardStore.Limits.CLOCK_SKEW_MS + 1uL, + expiresAt = now + BoardStore.Limits.CLOCK_SKEW_MS + 2uL + ) + ) + ) + } + + @Test + fun `per-author cap evicts oldest posts`() { + val store = BoardStore(nowMs = { now }) + repeat(BoardStore.Limits.MAX_POSTS_PER_AUTHOR + 1) { index -> + val post = signedPost( + idByte = index.toByte(), + createdAt = now + index.toULong(), + expiresAt = now + 86_400_000uL + ) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post)) + } + + val posts = store.posts("") + assertEquals(BoardStore.Limits.MAX_POSTS_PER_AUTHOR, posts.size) + assertTrue(posts.none { it.postID[0] == 0.toByte() }) + } + + @Test + fun `foreign tombstone does not delete known post`() { + val store = BoardStore(nowMs = { now }) + val post = signedPost() + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post)) + + val forged = signedTombstone(post, attacker) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestTombstone(forged)) + assertEquals(1, store.posts("").size) + + val valid = signedTombstone(post, author, deletedAt = now + 1uL) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestTombstone(valid)) + assertTrue(store.posts("").isEmpty()) + assertEquals(2, store.syncCandidates().size) + } + + @Test + fun `post id collision cannot suppress another author's post or deletion`() { + val store = BoardStore(nowMs = { now }) + val genuine = signedPost() + val collision = signedPost(key = attacker) + + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(collision)) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(genuine)) + assertEquals(2, store.posts("").size) + + assertEquals( + BoardIngestResult.ACCEPTED, + store.ingestTombstone(signedTombstone(genuine, author)) + ) + + val remaining = store.posts("") + assertEquals(1, remaining.size) + assertTrue(remaining.single().authorSigningKey.contentEquals(attacker.publicKey)) + } + + @Test + fun `rewrapped duplicate payload is stored but never relayed again`() { + val store = BoardStore(nowMs = { now }) + val wire = BoardWire.Post(signedPost()) + val firstOuterPacket = packet(wire) + + assertTrue(store.ingestRemoteForRelay(wire, firstOuterPacket)) + assertFalse( + store.ingestRemoteForRelay( + wire, + firstOuterPacket.copy(timestamp = firstOuterPacket.timestamp + 1uL) + ) + ) + assertEquals(1, store.posts("").size) + } + + @Test + fun `attacker orphan tombstone cannot block author deletion`() { + val store = BoardStore(nowMs = { now }) + val post = signedPost() + val attackerTombstone = signedTombstone(post, attacker) + + assertEquals( + BoardIngestResult.ACCEPTED, + store.ingestTombstone(attackerTombstone) + ) + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post)) + + val authorTombstone = signedTombstone(post, author, deletedAt = now + 1uL) + assertEquals( + BoardIngestResult.ACCEPTED, + store.ingestTombstone(authorTombstone) + ) + + assertTrue(store.posts("").isEmpty()) + assertEquals(2, store.syncCandidates().size) + assertEquals( + BoardIngestResult.DUPLICATE, + store.ingestTombstone(attackerTombstone) + ) + } + + @Test + fun `tombstone suppresses a stale copy until original expiry`() { + val store = BoardStore(nowMs = { now }) + val post = signedPost(expiresAt = now + 10_000uL) + store.ingestPost(post) + store.ingestTombstone(signedTombstone(post, author)) + + assertEquals(BoardIngestResult.REJECTED, store.ingestPost(post)) + now += 10_001uL + store.pruneExpired() + assertTrue(store.syncCandidates().isEmpty()) + } + + @Test + fun `signed packets persist and are reverified on load`() { + val file = temporaryFolder.newFile("posts.json") + file.delete() + val store = BoardStore(file = file, nowMs = { now }) + val post = signedPost(geohash = "u33dc1") + assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post)) + + val restored = BoardStore(file = file, nowMs = { now }) + + assertEquals(listOf(post), restored.posts("u33dc1")) + assertEquals(1, restored.syncCandidates().size) + } + + private fun BoardStore.ingestPost(post: BoardPostPacket): BoardIngestResult { + val wire = BoardWire.Post(post) + return ingest(wire, packet(wire), BoardIngestSource.REMOTE) + } + + private fun BoardStore.ingestTombstone( + tombstone: BoardTombstonePacket + ): BoardIngestResult { + val wire = BoardWire.Tombstone(tombstone) + return ingest(wire, packet(wire), BoardIngestSource.REMOTE) + } + + private fun packet(wire: BoardWire) = BitchatPacket( + type = MessageType.BOARD_POST.value, + senderID = ByteArray(8) { 1 }, + timestamp = now, + payload = BoardWireCodec.encode(wire), + ttl = 7u + ) + + private fun signedPost( + idByte: Byte = 1, + geohash: String = "", + createdAt: ULong = now, + expiresAt: ULong = now + 86_400_000uL, + key: Key = author + ): BoardPostPacket { + val postID = ByteArray(16).also { it[0] = idByte } + val content = "notice-$idByte" + val signingBytes = BoardPostPacket.signingBytes( + postID, + geohash, + content, + key.publicKey, + "alice", + createdAt, + expiresAt, + 0u + ) + return BoardPostPacket( + postID, + geohash, + content, + key.publicKey, + "alice", + createdAt, + expiresAt, + 0u, + key.sign(signingBytes) + ) + } + + private fun signedTombstone( + post: BoardPostPacket, + key: Key, + deletedAt: ULong = now + ): BoardTombstonePacket { + val signingBytes = BoardTombstonePacket.signingBytes(post.postID, deletedAt) + return BoardTombstonePacket( + post.postID, + key.publicKey, + deletedAt, + key.sign(signingBytes) + ) + } + + private class Key(seed: ByteArray) { + private val privateKey = Ed25519PrivateKeyParameters(seed, 0) + val publicKey: ByteArray = privateKey.generatePublicKey().encoded + + fun sign(message: ByteArray): ByteArray = Ed25519Signer().run { + init(true, privateKey) + update(message, 0, message.size) + generateSignature() + } + } +} diff --git a/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt b/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt new file mode 100644 index 00000000..9443a7de --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt @@ -0,0 +1,118 @@ +package com.bitchat.android.board + +import com.bitchat.android.nostr.LocationNotesManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UnifiedNoticesTest { + private val baseSeconds = 1_700_000_000 + private val baseMs = baseSeconds.toULong() * 1_000uL + + @Test + fun `bridged relay copy is deduplicated in favor of board post`() { + val post = post(content = "water at the gate") + val relayCopy = note(content = "water at the gate", createdAt = baseSeconds + 30) + + val result = UnifiedNotices.merge("u33dc", listOf(post), listOf(relayCopy)) + + assertEquals(1, result.size) + assertEquals(NoticeSource.MESH, result.single().source) + } + + @Test + fun `same content from neighbor cell is retained`() { + val post = post(content = "free tent") + val neighbor = note(content = "free tent", geohash = "u33dd") + + val result = UnifiedNotices.merge("u33dc", listOf(post), listOf(neighbor)) + + assertEquals(2, result.size) + assertTrue(result.any { it.source == NoticeSource.NOSTR }) + } + + @Test + fun `different author and out of window notes remain`() { + val post = post(content = "meet at six") + val otherAuthor = note(content = "meet at six", nickname = "bob") + val oldCopy = note( + content = "meet at six", + createdAt = baseSeconds - 16 * 60 + ) + + val result = UnifiedNotices.merge( + "u33dc", + listOf(post), + listOf(otherAuthor, oldCopy) + ) + + assertEquals(3, result.size) + } + + @Test + fun `anonymous copies deduplicate and urgent sorts first`() { + val anonymous = post(content = "hello", nickname = "") + val bridged = note(content = "hello", nickname = null) + val urgentRelay = note( + content = "road closed", + nickname = "carol", + createdAt = baseSeconds - 60, + urgent = true + ) + + val result = UnifiedNotices.merge( + "u33dc", + listOf(anonymous), + listOf(bridged, urgentRelay) + ) + + assertEquals(listOf("road closed", "hello"), result.map { it.content }) + assertTrue(result.first().urgent) + assertFalse(result.last().urgent) + } + + @Test + fun `colliding post ids from different authors retain unique row ids`() { + val first = post(content = "first") + val second = post(content = "second", authorKeyByte = 2) + + val result = UnifiedNotices.merge("u33dc", listOf(first, second), emptyList()) + + assertEquals(2, result.map { it.id }.distinct().size) + } + + private fun post( + content: String, + nickname: String = "alice", + createdAt: ULong = baseMs, + urgent: Boolean = false, + authorKeyByte: Byte = 1 + ) = BoardPostPacket( + postID = ByteArray(16) { 7 }, + geohash = "u33dc", + content = content, + authorSigningKey = ByteArray(32) { authorKeyByte }, + authorNickname = nickname, + createdAt = createdAt, + expiresAt = createdAt + 86_400_000uL, + flags = if (urgent) BoardPostPacket.URGENT_FLAG else 0u, + signature = ByteArray(64) { 2 } + ) + + private fun note( + content: String, + nickname: String? = "alice", + createdAt: Int = baseSeconds, + geohash: String = "u33dc", + urgent: Boolean = false + ) = LocationNotesManager.Note( + id = "$content-$nickname-$createdAt-$geohash", + pubkey = "deadbeef", + content = content, + createdAt = createdAt, + nickname = nickname, + geohash = geohash, + isUrgent = urgent + ) +} diff --git a/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt b/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt new file mode 100644 index 00000000..a0eda7d9 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.crypto + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.noise.NoiseEncryptionService +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class NoiseCourierTest { + @Test + fun `Noise X seal authenticates sender and rejects tampering`() { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("noise-courier-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val service = NoiseEncryptionService( + context, + SecureIdentityStateManager(prefs, testOnly = true) + ) + val payload = byteArrayOf(1, 0, 1, 65, 1, 1, 66) + + val sealed = service.sealCourierPayload(payload, service.getStaticPublicKeyData()) + val (senderKey, opened) = service.openCourierPayload(sealed) + + assertEquals(payload.size + 96, sealed.size) + assertArrayEquals(service.getStaticPublicKeyData(), senderKey) + assertArrayEquals(payload, opened) + + sealed[sealed.lastIndex] = (sealed.last().toInt() xor 1).toByte() + assertThrows(Exception::class.java) { service.openCourierPayload(sealed) } + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt index c4225288..49d31c5a 100644 --- a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -83,6 +83,25 @@ class FragmentingPacketSenderTest { assertTrue(writes > 0) } + @Test + fun `awaited fragmented send reports a later fragment rejection`() = runBlocking { + val sender = FragmentingPacketSender( + CoroutineScope(Dispatchers.Default + SupervisorJob()), + FragmentManager(), + "test", + interFragmentDelayMs = 0L + ) + var writes = 0 + + val accepted = sender.sendAndAwaitAcceptance(RoutedPacket(packetWithPayload(10_000)), "test") { + writes += 1 + writes < 2 + } + + assertFalse(accepted) + assertTrue(writes >= 2) + } + @Test fun `fragment count at cap boundary is not rejected`() { val manager = FragmentManager() diff --git a/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt b/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt new file mode 100644 index 00000000..0f3a294d --- /dev/null +++ b/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt @@ -0,0 +1,206 @@ +package com.bitchat.android.sync + +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.cancel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class BoardSyncTest { + @Test + fun `board flag widens to second little-endian byte`() { + assertArrayEquals(byteArrayOf(0, 1), SyncTypeFlags.BOARD.encode()) + val decoded = SyncTypeFlags.decode(byteArrayOf(0, 1))!! + assertTrue(decoded.contains(MessageType.BOARD_POST)) + assertFalse(decoded.contains(MessageType.MESSAGE)) + } + + @Test + fun `legacy one-byte flags and unknown bits remain compatible`() { + val legacy = SyncTypeFlags.decode(byteArrayOf(0x03))!! + assertTrue(legacy.contains(MessageType.ANNOUNCE)) + assertTrue(legacy.contains(MessageType.MESSAGE)) + assertFalse(legacy.contains(MessageType.BOARD_POST)) + + val unknownOnly = SyncTypeFlags.decode(byteArrayOf(0, 0, 0x40))!! + assertNull(unknownOnly.encode()) + } + + @Test + fun `request sync round trips board types and cursor`() { + val request = RequestSyncPacket( + p = 7, + m = 1234, + data = byteArrayOf(1, 2, 3), + types = SyncTypeFlags.BOARD, + sinceTimestamp = 1_700_000_000_000uL + ) + + val decoded = RequestSyncPacket.decode(request.encode())!! + + assertEquals(7, decoded.p) + assertEquals(1234, decoded.m) + assertArrayEquals(byteArrayOf(1, 2, 3), decoded.data) + assertTrue(decoded.types!!.contains(MessageType.BOARD_POST)) + assertEquals(1_700_000_000_000uL, decoded.sinceTimestamp) + } + + @Test + fun `legacy request without type field defaults at handler not decoder`() { + val request = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0)) + val decoded = RequestSyncPacket.decode(request.encode())!! + + assertNull(decoded.types) + assertNull(decoded.sinceTimestamp) + } + + @Test + fun `board round is served only from provider`() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val manager = GossipSyncManager( + myPeerID = "0102030405060708", + scope = scope, + configProvider = config() + ) + val boardPacket = packet(MessageType.BOARD_POST, timestamp = 200uL) + val messagePacket = packet(MessageType.MESSAGE, timestamp = 300uL) + manager.boardPacketsProvider = { listOf(boardPacket) } + manager.onPublicPacketSeen(messagePacket) + val sent = mutableListOf() + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent += packet + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet + } + + manager.handleRequestSync( + fromPeerID = "1111111111111111", + request = RequestSyncPacket( + p = 7, + m = 1, + data = ByteArray(0), + types = SyncTypeFlags.BOARD + ) + ) + + assertEquals(listOf(MessageType.BOARD_POST.value), sent.map { it.type }) + assertEquals(0u.toUByte(), sent.single().ttl) + scope.cancel() + } + + @Test + fun `initial sync omits board for a legacy peer`() { + val requestedTypes = initialSyncTypes( + announcementCapabilities = null, + observeBoardRequest = false + ) + + assertTrue(requestedTypes.contains(MessageType.ANNOUNCE)) + assertTrue(requestedTypes.contains(MessageType.MESSAGE)) + assertFalse(requestedTypes.contains(MessageType.BOARD_POST)) + } + + @Test + fun `initial sync includes board for an advertising peer`() { + val requestedTypes = initialSyncTypes( + announcementCapabilities = PeerCapabilities.BOARD, + observeBoardRequest = false + ) + + assertTrue(requestedTypes.contains(MessageType.BOARD_POST)) + } + + @Test + fun `a board request opts current iOS peers into future board sync`() { + val requestedTypes = initialSyncTypes( + announcementCapabilities = null, + observeBoardRequest = true + ) + + assertTrue(requestedTypes.contains(MessageType.BOARD_POST)) + } + + private fun config() = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + } + + private fun packet(type: MessageType, timestamp: ULong) = BitchatPacket( + type = type.value, + senderID = ByteArray(8) { 1 }, + timestamp = timestamp, + payload = byteArrayOf(1, 2, 3), + ttl = 7u + ) + + private fun initialSyncTypes( + announcementCapabilities: PeerCapabilities?, + observeBoardRequest: Boolean + ): SyncTypeFlags { + val peerID = "1111111111111111" + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val manager = GossipSyncManager( + myPeerID = "0102030405060708", + scope = scope, + configProvider = config() + ) + manager.boardPacketsProvider = { emptyList() } + manager.onPublicPacketSeen( + BitchatPacket( + type = MessageType.ANNOUNCE.value, + senderID = ByteArray(8) { 0x11 }, + timestamp = System.currentTimeMillis().toULong(), + payload = IdentityAnnouncement( + nickname = "peer", + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = ByteArray(32) { 2 }, + capabilities = announcementCapabilities + ).encode()!!, + ttl = 7u + ) + ) + if (observeBoardRequest) { + manager.handleRequestSync( + fromPeerID = peerID, + request = RequestSyncPacket( + p = 7, + m = 1, + data = ByteArray(0), + types = SyncTypeFlags.BOARD + ) + ) + } + + val latch = CountDownLatch(1) + var sent: BitchatPacket? = null + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent = packet + latch.countDown() + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet + } + manager.scheduleInitialSyncToPeer(peerID, delayMs = 0) + + assertTrue("initial sync was not sent", latch.await(2, TimeUnit.SECONDS)) + val types = RequestSyncPacket.decode(requireNotNull(sent).payload)?.types + scope.cancel() + return requireNotNull(types) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 2e52b750..fd2a11ee 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -6,7 +6,9 @@ import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.services.meshgraph.MeshGraphService import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue @@ -45,6 +47,7 @@ class CommandProcessorTest { @Before fun setup() { + MeshGraphService.resetForTesting() commandProcessor = CommandProcessor( state = chatState, messageManager = messageManager, diff --git a/app/src/test/java/com/bitchat/android/ui/NoticeComposerPolicyTest.kt b/app/src/test/java/com/bitchat/android/ui/NoticeComposerPolicyTest.kt new file mode 100644 index 00000000..553e6813 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/NoticeComposerPolicyTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NoticeComposerPolicyTest { + @Test + fun `geo offers permanent and finite board lifetimes`() { + assertEquals(listOf(0, 1, 3, 7), NoticeComposerPolicy.expiryOptions(isGeo = true)) + } + + @Test + fun `mesh offers only finite board lifetimes`() { + assertEquals(listOf(1, 3, 7), NoticeComposerPolicy.expiryOptions(isGeo = false)) + } + + @Test + fun `only permanent geo selection skips the board`() { + assertTrue( + NoticeComposerPolicy.isPermanentRelayOnlyGeo( + isGeo = true, + expiryDays = 0 + ) + ) + assertFalse( + NoticeComposerPolicy.isPermanentRelayOnlyGeo( + isGeo = true, + expiryDays = 1 + ) + ) + assertFalse( + NoticeComposerPolicy.isPermanentRelayOnlyGeo( + isGeo = false, + expiryDays = 1 + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt index d1381170..d627fd72 100644 --- a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt @@ -9,6 +9,7 @@ import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.sync.SyncTypeFlags import com.bitchat.android.model.UnknownAnnouncementTLV import com.bitchat.android.protocol.BinaryProtocol import com.bitchat.android.protocol.BitchatPacket @@ -203,9 +204,14 @@ class ClientRewriteWireContractTest { val request = RequestSyncPacket( p = 19, m = 0x01020304L, - data = hex("aabb") + data = hex("aabb"), + types = SyncTypeFlags.FRAGMENTS_AND_FILES, + sinceTimestamp = 0x0102030405060708u + ) + val wire = hex( + "0100011302000401020304030002aabb" + + "040001a00500080102030405060708" ) - val wire = hex("0100011302000401020304030002aabb") assertArrayEquals(wire, request.encode()) assertSyncRequestEquals(request, RequestSyncPacket.decode(wire)) @@ -297,5 +303,7 @@ class ClientRewriteWireContractTest { assertEquals(expected.p, actual!!.p) assertEquals(expected.m, actual.m) assertArrayEquals(expected.data, actual.data) + assertEquals(expected.types, actual.types) + assertEquals(expected.sinceTimestamp, actual.sinceTimestamp) } } diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt new file mode 100644 index 00000000..c4586e4c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt @@ -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() + val additionalPeerIDs = mutableMapOf>() + val connected = mutableSetOf() + val groupCapabilities = mutableMapOf() + val peerNames = mutableMapOf() + val identities = mutableMapOf() + val connectedFingerprints = mutableMapOf() + val blocked = mutableSetOf() + val invites = mutableListOf>() + val updates = mutableListOf>() + val broadcasts = mutableListOf() + val messages = mutableMapOf>() + val unread = mutableSetOf() + val removedConversations = mutableSetOf() + val systemMessages = mutableListOf() + val notifications = mutableListOf>() + + 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 = + 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() + + 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 + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt new file mode 100644 index 00000000..c4f5c495 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt @@ -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 = 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" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt new file mode 100644 index 00000000..35982cda --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt @@ -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() + + 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 + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt new file mode 100644 index 00000000..a389a491 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt @@ -0,0 +1,187 @@ +package com.bitchat.android.identity + +import android.content.Context +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class VouchPersistenceTest { + private lateinit var manager: SecureIdentityStateManager + private lateinit var prefs: android.content.SharedPreferences + private val voucher = fingerprint(VOUCHER_INDEX) + private val vouchee = fingerprint(VOUCHEE_INDEX) + private val voucheeSigningKey = ByteArray(VouchAttestation.SIGNING_KEY_SIZE) { + VOUCHEE_SIGNING_KEY_BYTE + } + + @Before + fun setup() { + prefs = RuntimeEnvironment.getApplication().getSharedPreferences( + "$PREFS_PREFIX${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + manager = SecureIdentityStateManager(prefs, testOnly = true) + manager.clearIdentityData() + manager.setVerifiedFingerprint(voucher, true) + manager.storeAuthenticatedPeerState( + vouchee, + AuthenticatedPeerState(PeerCapabilities.VOUCH, voucheeSigningKey) + ) + } + + @After + fun tearDown() { manager.clearIdentityData() } + + @Test + fun `vouch persists and derives trust only while voucher remains verified`() { + assertTrue( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + assertTrue(SecureIdentityStateManager(prefs, testOnly = true).isVouched(vouchee, TEST_NOW_MS)) + + manager.setVerifiedFingerprint(voucher, false) + assertFalse(manager.isVouched(vouchee, TEST_NOW_MS)) + manager.setVerifiedFingerprint(voucher, true) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + } + + @Test + fun `storage rejects self verified stale and future vouches`() { + assertFalse( + manager.recordVouch(voucher, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + manager.setVerifiedFingerprint(vouchee, true) + assertFalse( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + manager.setVerifiedFingerprint(vouchee, false) + assertFalse( + manager.recordVouch( + vouchee, + voucher, + voucheeSigningKey, + TEST_NOW_MS - VouchAttestation.MAX_AGE_MS - INVALID_TIME_DELTA_MS, + TEST_NOW_MS + ) + ) + assertFalse( + manager.recordVouch( + vouchee, + voucher, + voucheeSigningKey, + TEST_NOW_MS + VouchAttestation.MAX_CLOCK_SKEW_MS + INVALID_TIME_DELTA_MS, + TEST_NOW_MS + ) + ) + } + + @Test + fun `only the most recent bounded voucher set is retained`() { + repeat(SecureIdentityStateManager.MAX_VOUCHERS_PER_VOUCHEE + EXTRA_VOUCHER_COUNT) { index -> + val candidate = fingerprint(index + FIRST_GENERATED_VOUCHER_INDEX) + manager.setVerifiedFingerprint(candidate, true) + manager.recordVouch( + vouchee, + candidate, + voucheeSigningKey, + TEST_NOW_MS + index, + TEST_NOW_MS + ) + } + + val records = manager.validVouchers(vouchee, TEST_NOW_MS) + assertEquals(SecureIdentityStateManager.MAX_VOUCHERS_PER_VOUCHEE, records.size) + assertFalse(records.any { it.voucherFingerprint == fingerprint(FIRST_GENERATED_VOUCHER_INDEX) }) + } + + @Test + fun `vouch only counts for the attested authenticated signing key`() { + assertTrue( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + + manager.storeAuthenticatedPeerState( + vouchee, + AuthenticatedPeerState(PeerCapabilities.VOUCH, rotatedSigningKey()) + ) + assertFalse(manager.isVouched(vouchee, TEST_NOW_MS)) + + manager.storeAuthenticatedPeerState( + vouchee, + AuthenticatedPeerState(PeerCapabilities.VOUCH, voucheeSigningKey) + ) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + } + + @Test + fun `next expiry tracks when derived trust must refresh`() { + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + val expectedExpiry = TEST_NOW_MS + VouchAttestation.MAX_AGE_MS + EXPIRY_TRANSITION_OFFSET_MS + + assertEquals(expectedExpiry, manager.nextVouchExpiryMs(TEST_NOW_MS)) + assertEquals(null, manager.nextVouchExpiryMs(expectedExpiry)) + } + + @Test + fun `rate limit and vouch graph clear with panic wipe`() { + manager.markVouchBatchSent(voucher, TEST_NOW_MS) + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + assertEquals(TEST_NOW_MS, manager.lastVouchBatchSent(voucher)) + + manager.clearIdentityData() + assertEquals(null, manager.lastVouchBatchSent(voucher)) + assertTrue(manager.validVouchers(vouchee, TEST_NOW_MS).isEmpty()) + } + + @Test + fun `manager surviving panic cannot recreate vouch state`() { + val wipingManager = SecureIdentityStateManager(prefs, testOnly = true) + wipingManager.clearIdentityData() + + assertFalse( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + manager.markVouchBatchSent(voucher, TEST_NOW_MS) + + val reloaded = SecureIdentityStateManager(prefs, testOnly = true) + assertTrue(reloaded.validVouchers(vouchee, TEST_NOW_MS).isEmpty()) + assertEquals(null, reloaded.lastVouchBatchSent(voucher)) + } + + private fun rotatedSigningKey() = ByteArray(VouchAttestation.SIGNING_KEY_SIZE) { + ROTATED_SIGNING_KEY_BYTE + } + + private fun fingerprint(index: Int): String = + index.toString(HEX_RADIX).padStart(FINGERPRINT_HEX_LENGTH, FINGERPRINT_PAD_CHAR) + .takeLast(FINGERPRINT_HEX_LENGTH) + + companion object { + private const val PREFS_PREFIX = "vouch-persistence-" + private const val VOUCHER_INDEX = 1 + private const val VOUCHEE_INDEX = 2 + private const val FIRST_GENERATED_VOUCHER_INDEX = 10 + private const val EXTRA_VOUCHER_COUNT = 2 + private const val INVALID_TIME_DELTA_MS = 1L + private const val EXPIRY_TRANSITION_OFFSET_MS = 1L + private const val VOUCHEE_SIGNING_KEY_BYTE: Byte = 0x31 + private const val ROTATED_SIGNING_KEY_BYTE: Byte = 0x32 + private const val TEST_NOW_MS = 1_700_000_000_000L + private const val HEX_RADIX = 16 + private const val FINGERPRINT_HEX_LENGTH = VouchAttestation.FINGERPRINT_SIZE * 2 + private const val FINGERPRINT_PAD_CHAR = '0' + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt new file mode 100644 index 00000000..121c0f91 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothPacketBroadcasterCompletionTest.kt @@ -0,0 +1,110 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.os.Build +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class BluetoothPacketBroadcasterCompletionTest { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + private val broadcaster = BluetoothPacketBroadcaster(scope, tracker, null, MY_PEER_ID) + + @After + fun tearDown() { + broadcaster.shutdown() + scope.cancel() + } + + @Test + fun `queue admission waits for successful GATT completion`() = runBlocking { + val connection = connectedPeer() + + val result = async(Dispatchers.Default) { + broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null) + } + + verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!) + assertFalse(result.isCompleted) + + broadcaster.onGattClientWriteComplete(DEVICE_ADDRESS, LINK_ID, BluetoothGatt.GATT_SUCCESS) + assertTrue(result.await()) + } + + @Test + fun `disconnect fails an admitted send before custody can be released`() = runBlocking { + val connection = connectedPeer() + + val result = async(Dispatchers.Default) { + broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null) + } + + verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!) + assertFalse(result.isCompleted) + + broadcaster.onLinkDisconnected(DEVICE_ADDRESS, LINK_ID) + assertFalse(result.await()) + } + + private fun connectedPeer(): BluetoothConnectionTracker.DeviceConnection { + val device = mock() + val gatt = mock() + val characteristic = mock() + whenever(device.address).thenReturn(DEVICE_ADDRESS) + whenever(gatt.writeCharacteristic(any())).thenReturn(true) + val connection = BluetoothConnectionTracker.DeviceConnection( + device = device, + gatt = gatt, + characteristic = characteristic, + isClient = true, + linkID = LINK_ID + ) + tracker.addDeviceConnection(DEVICE_ADDRESS, connection) + tracker.observePeerIfCurrent(DEVICE_ADDRESS, LINK_ID, PEER_ID) + return connection + } + + private fun packet() = RoutedPacket( + BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = MY_PEER_ID.hexToBytes(), + recipientID = PEER_ID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(1, 2, 3), + ttl = 7u + ) + ) + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val MY_PEER_ID = "1111222233334444" + const val PEER_ID = "aaaabbbbccccdddd" + const val DEVICE_ADDRESS = "00:11:22:33:44:55" + const val LINK_ID = "synthetic-link" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt new file mode 100644 index 00000000..6d9a9482 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt @@ -0,0 +1,148 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.services.ConversationStorageCipher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class CourierStoreTest { + private val now = 2_000_000L + private val recipient = ByteArray(32) { 7 } + private val favorite = ByteArray(32) { 8 } + private val verified = ByteArray(32) { 9 } + private val cipher = TestCipher(0x5a) + + @Test + fun `direct pickup remains retryable until explicitly removed`() { + val store = newStore() + val envelope = envelope(1) + assertTrue(store.deposit(envelope, favorite, CourierDepositTier.FAVORITE)) + + assertEquals(1, store.copiesForRecipient(recipient).size) + assertEquals(1, store.copiesForRecipient(recipient).size) + assertTrue(store.remove(envelope)) + assertTrue(store.copiesForRecipient(recipient).isEmpty()) + } + + @Test + fun `verified pool evicts oldest verified and never favorites`() { + val store = newStore() + repeat(20) { index -> + assertTrue(store.deposit(envelope(index), ByteArray(32) { index.toByte() }, CourierDepositTier.VERIFIED)) + } + val favoriteEnvelope = envelope(100) + assertTrue(store.deposit(favoriteEnvelope, favorite, CourierDepositTier.FAVORITE)) + assertTrue(store.deposit(envelope(200), ByteArray(32) { 100 }, CourierDepositTier.VERIFIED)) + + val copies = reloaded().copiesForRecipient(recipient) + assertEquals(21, copies.size) + assertTrue(copies.any { it.ciphertext.contentEquals(favoriteEnvelope.ciphertext) }) + } + + @Test + fun `spray history and copy budget survive restart`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 11 } + + val spray = store.sprayCopiesFor(courier).single() + assertEquals(2u.toUByte(), spray.copies) + assertTrue(store.commitSpray(spray, courier)) + assertTrue(reloaded().sprayCopiesFor(courier).isEmpty()) + } + + @Test + fun `failed spray preview does not consume custody`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 12 } + + assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies) + assertEquals(2u.toUByte(), reloaded().sprayCopiesFor(courier).single().copies) + } + + @Test + fun `concurrent spray reservations cannot over allocate custody`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val firstCourier = ByteArray(32) { 12 } + val secondCourier = ByteArray(32) { 13 } + val thirdCourier = ByteArray(32) { 14 } + + val first = store.sprayCopiesFor(firstCourier).single() + val second = store.sprayCopiesFor(secondCourier).single() + + assertEquals(2u.toUByte(), first.copies) + assertEquals(1u.toUByte(), second.copies) + assertTrue(store.sprayCopiesFor(firstCourier).isEmpty()) + assertTrue(store.sprayCopiesFor(thirdCourier).isEmpty()) + assertTrue(store.commitSpray(second, secondCourier)) + assertTrue(store.commitSpray(first, firstCourier)) + assertTrue(reloaded().sprayCopiesFor(thirdCourier).isEmpty()) + } + + @Test + fun `cancelled spray reservation makes its copies eligible again`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 15 } + + val first = store.sprayCopiesFor(courier).single() + + assertTrue(store.cancelSpray(first, courier)) + assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies) + } + + @Test + fun `stored prekey envelope retains its prekey id through spray`() { + val store = newStore() + val envelope = envelope(1, copies = 4u).copy(prekeyID = 0x11223344u) + assertTrue(store.deposit(envelope, verified, CourierDepositTier.VERIFIED)) + + val spray = store.sprayCopiesFor(ByteArray(32) { 16 }).single() + + assertEquals(0x11223344u, spray.prekeyID) + } + + @Test + fun `wipe deletes sealed custody and destroys key`() { + val store = newStore() + assertTrue(store.deposit(envelope(1), favorite, CourierDepositTier.FAVORITE)) + store.wipe() + + assertFalse(File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").exists()) + assertTrue(cipher.destroyed) + } + + private fun envelope(id: Int, copies: UByte = 1u) = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipient, CourierEnvelope.epochDay(now)), + expiry = (now + 60_000).toULong(), + ciphertext = byteArrayOf((id ushr 8).toByte(), id.toByte()), + copies = copies + ) + + private fun newStore(): CourierStore { + File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").delete() + return reloaded() + } + + private fun reloaded() = CourierStore(RuntimeEnvironment.getApplication(), cipher) { now } + + private class TestCipher(private val mask: Int) : ConversationStorageCipher { + var destroyed = false + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = encrypt(envelope, associatedData) + override fun destroyKey() { destroyed = true } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt new file mode 100644 index 00000000..559b07b4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DirectCourierDepositPolicyTest { + private val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = ByteArray(8), + timestamp = 1u, + payload = byteArrayOf(1), + ttl = 7u + ) + + @Test + fun `accepts current direct ingress from claimed sender`() { + val routed = RoutedPacket(packet, "peer", "address", ingressLinkID = "link") + assertTrue(DirectCourierDepositPolicy.accepts(routed, 7u, { "link" }, { "peer" })) + } + + @Test + fun `rejects relayed stale-link and rebound-sender deposits`() { + val direct = RoutedPacket(packet, "peer", "address", ingressLinkID = "link") + assertFalse(DirectCourierDepositPolicy.accepts(direct.copy(packet = packet.copy(ttl = 6u)), 7u, { "link" }, { "peer" })) + assertFalse(DirectCourierDepositPolicy.accepts(direct, 7u, { "replacement" }, { "peer" })) + assertFalse(DirectCourierDepositPolicy.accepts(direct, 7u, { "link" }, { "other" })) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MeshPingManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MeshPingManagerTest.kt new file mode 100644 index 00000000..91a22b44 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MeshPingManagerTest.kt @@ -0,0 +1,51 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MeshDiagnosticsConstants +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MeshPingManagerTest { + private val localPeerID = "1111111111111111" + private val remotePeerID = "2222222222222222" + + @Test + fun `pong received on another transport completes pending ping`() = runTest { + var outbound: BitchatPacket? = null + var callbackCount = 0 + var result: MeshPingResult? = null + val originTransport = MeshPingManager(localPeerID, this) { outbound = it } + val returnTransport = MeshPingManager(localPeerID, this) {} + + originTransport.ping(remotePeerID) { + callbackCount += 1 + result = it + } + + val ping = requireNotNull(outbound) + returnTransport.handlePong( + RoutedPacket( + packet = ping.copy( + type = MessageType.PONG.value, + senderID = MeshPacketUtils.hexStringToByteArray(remotePeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(localPeerID), + timestamp = System.currentTimeMillis().toULong(), + ttl = (MeshDiagnosticsConstants.TTL - 1u).toUByte(), + ), + peerID = remotePeerID, + ) + ) + + assertEquals(1, callbackCount) + assertNotNull(result) + assertEquals(2, result?.hopCount) + assertTrue(requireNotNull(result).rttMillis >= 0) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 79bc4c2d..29e1b825 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -7,6 +7,7 @@ import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.RoutedPacket import com.bitchat.android.noise.NoisePeerIdentity import com.bitchat.android.noise.AuthenticatedNoiseSession @@ -25,9 +26,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -435,6 +438,48 @@ class MessageHandlerTest { assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) } + @Test + fun `opened courier message retains its authenticated sender but cannot acknowledge without persistence`() = runBlocking { + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.getMyNickname()).thenReturn("me") + val payload = NoisePayload( + NoisePayloadType.PRIVATE_MESSAGE, + requireNotNull(PrivateMessagePacket("courier-message", "opaque courier content").encode()) + ).encode() + + assertFalse( + handler.handleOpenedCourierPayload( + RoutedPacket(encryptedPacket().copy(payload = payload), peerID, "courier-ingress") + ) + ) + verify(delegate).onMessageReceived(argThat { + id == "courier-message" && + content == "opaque courier content" && + senderPeerID == peerID && + sender == nickname && + recipientNickname == "me" + }) + } + + @Test + fun `opened courier delivery receipt acknowledges the original sender and rejects blank IDs`() = runBlocking { + val receipt = NoisePayload(NoisePayloadType.DELIVERED, "courier-message".toByteArray()).encode() + assertTrue( + handler.handleOpenedCourierPayload( + RoutedPacket(encryptedPacket().copy(payload = receipt), peerID, "courier-ingress") + ) + ) + verify(delegate).onDeliveryAckReceived("courier-message", peerID) + + val blankReceipt = NoisePayload(NoisePayloadType.DELIVERED, ByteArray(0)).encode() + assertFalse( + handler.handleOpenedCourierPayload( + RoutedPacket(encryptedPacket().copy(payload = blankReceipt), peerID, "courier-ingress") + ) + ) + verify(delegate, times(1)).onDeliveryAckReceived("courier-message", peerID) + } + private fun encryptedPacket(): BitchatPacket = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt index 3a676382..2564273b 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -64,6 +64,26 @@ class PacketProcessorAnnounceSideEffectTest { assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() }) } + @Test + fun `broadcast message within future skew is handled`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(message(timestampOffsetMs = 60_000)) + + withTimeout(1_000) { delegate.messageHandled.await() } + } + + @Test + fun `broadcast message beyond future skew is rejected`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(message(timestampOffsetMs = 11 * 60_000L)) + + assertNull(withTimeoutOrNull(250) { delegate.messageHandled.await() }) + } + private fun processor(delegate: RecordingDelegate): PacketProcessor = PacketProcessor(MY_PEER_ID).also { it.delegate = delegate @@ -96,12 +116,26 @@ class PacketProcessorAnnounceSideEffectTest { return RoutedPacket(packet, PEER_ID, "direct-link") } + private fun message(timestampOffsetMs: Long): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = PEER_ID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() + timestampOffsetMs).toULong(), + payload = byteArrayOf(0x01), + ttl = 7u + ) + return RoutedPacket(packet, PEER_ID, "direct-link") + } + private class RecordingDelegate( private val acceptAnnounce: Boolean, private val acceptHandshake: Boolean = false ) : PacketProcessorDelegate { val handled = CompletableDeferred() val handshakeHandled = CompletableDeferred() + val messageHandled = CompletableDeferred() val lastSeen = CompletableDeferred() @Volatile var relayCount = 0 @@ -121,7 +155,9 @@ class PacketProcessorAnnounceSideEffectTest { handled.complete(Unit) return acceptAnnounce } - override fun handleMessage(routed: RoutedPacket) = Unit + override fun handleMessage(routed: RoutedPacket) { + messageHandled.complete(Unit) + } override fun handleLeave(routed: RoutedPacket) = Unit override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null override fun handleRequestSync(routed: RoutedPacket) = Unit diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt index ae896e53..c962542f 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -3,6 +3,7 @@ package com.bitchat.android.mesh import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MeshDiagnosticsConstants import com.bitchat.android.protocol.MessageType import com.bitchat.android.util.toHexString import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -12,6 +13,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -34,9 +36,13 @@ class PacketRelayManagerTest { whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0)) } - private fun createPacket(route: List?, recipient: String? = null): BitchatPacket { + private fun createPacket( + route: List?, + recipient: String? = null, + type: MessageType = MessageType.MESSAGE, + ): BitchatPacket { return BitchatPacket( - type = MessageType.MESSAGE.value, + type = type.value, senderID = hexStringToPeerBytes(otherPeerID), recipientID = recipient?.let { hexStringToPeerBytes(it) }, timestamp = System.currentTimeMillis().toULong(), @@ -103,6 +109,57 @@ class PacketRelayManagerTest { verify(delegate).broadcastPacket(any()) } + @Test + fun `diagnostic transit relay budget is enforced per ingress link`() = runTest { + val attempts = MeshDiagnosticsConstants.INBOUND_RATE_LIMIT + 1 + + repeat(attempts) { index -> + val packet = createPacket( + route = null, + recipient = finalRecipientID, + type = MessageType.PING, + ).copy(payload = byteArrayOf(index.toByte())) + packetRelayManager.handlePacketRelay( + RoutedPacket( + packet = packet, + peerID = otherPeerID, + ingressLinkID = "ingress-link", + ) + ) + } + + verify(delegate, times(MeshDiagnosticsConstants.INBOUND_RATE_LIMIT)) + .broadcastPacket(any()) + } + + @Test + fun `diagnostic source route uses the same ingress relay budget`() = runTest { + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + val route = listOf( + hexStringToPeerBytes(myPeerID), + hexStringToPeerBytes(nextHopPeerID), + ) + val attempts = MeshDiagnosticsConstants.INBOUND_RATE_LIMIT + 1 + + repeat(attempts) { index -> + val packet = createPacket( + route = route, + recipient = finalRecipientID, + type = MessageType.PONG, + ).copy(payload = byteArrayOf(index.toByte())) + packetRelayManager.handlePacketRelay( + RoutedPacket( + packet = packet, + peerID = otherPeerID, + ingressLinkID = "source-route-ingress", + ) + ) + } + + verify(delegate, times(MeshDiagnosticsConstants.INBOUND_RATE_LIMIT)) + .sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any()) + } + private fun hexStringToPeerBytes(hex: String): ByteArray { val result = ByteArray(8) var idx = 0 diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/VouchCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/VouchCoordinatorTest.kt new file mode 100644 index 00000000..251130c7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/VouchCoordinatorTest.kt @@ -0,0 +1,133 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class VouchCoordinatorTest { + private lateinit var identity: SecureIdentityStateManager + private lateinit var scope: CoroutineScope + private val sentPayloads = mutableListOf() + private val peerFingerprint = fingerprint(PEER_FINGERPRINT_INDEX) + private val voucheeFingerprint = fingerprint(VOUCHEE_FINGERPRINT_INDEX) + private var capabilities = PeerCapabilities.VOUCH + + @Before + fun setup() { + val prefs = RuntimeEnvironment.getApplication().getSharedPreferences( + "$PREFS_PREFIX${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + identity = SecureIdentityStateManager(prefs, testOnly = true) + identity.clearIdentityData() + identity.setVerifiedFingerprint(peerFingerprint, true) + identity.setVerifiedFingerprint(voucheeFingerprint, true) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + } + + @After + fun tearDown() { + scope.cancel() + identity.clearIdentityData() + } + + @Test + fun `send policy excludes recipient and persists interval`() { + val coordinator = coordinator() + + assertTrue(coordinator.attemptVouch(PEER_ID, peerFingerprint, TEST_NOW_MS)) + val firstBatch = VouchAttestation.decodeList(sentPayloads.single()) + assertEquals(SINGLE_ATTESTATION_COUNT, firstBatch.size) + assertEquals(voucheeFingerprint, firstBatch.single().voucheeFingerprint.toHex()) + assertFalse( + coordinator.attemptVouch( + PEER_ID, + peerFingerprint, + TEST_NOW_MS + VouchCoordinator.BATCH_INTERVAL_MS - BEFORE_INTERVAL_DELTA_MS + ) + ) + assertTrue( + coordinator.attemptVouch( + PEER_ID, + peerFingerprint, + TEST_NOW_MS + VouchCoordinator.BATCH_INTERVAL_MS + ) + ) + } + + @Test + fun `unsupported capability blocks send but unknown remains race tolerant`() { + capabilities = PeerCapabilities.PRIVATE_MEDIA + assertFalse(coordinator().attemptVouch(PEER_ID, peerFingerprint, TEST_NOW_MS)) + + capabilities = PeerCapabilities.NONE + assertTrue(coordinator().attemptVouch(PEER_ID, peerFingerprint, TEST_NOW_MS)) + } + + private fun coordinator() = VouchCoordinator( + scope = scope, + identity = identity, + connectedPeerIDs = { listOf(PEER_ID) }, + fingerprintForPeer = { peerFingerprint }, + peerInfo = { + PeerInfo( + id = PEER_ID, + nickname = PEER_NICKNAME, + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(VouchAttestation.FINGERPRINT_SIZE), + signingPublicKey = ByteArray(VouchAttestation.SIGNING_KEY_SIZE), + isVerifiedNickname = true, + lastSeen = TEST_NOW_MS, + capabilities = capabilities + ) + }, + signingKeyForFingerprint = { ByteArray(VouchAttestation.SIGNING_KEY_SIZE) }, + hasEstablishedSession = { true }, + sign = { ByteArray(VouchAttestation.SIGNATURE_SIZE) }, + verify = { _, _, _ -> true }, + send = { _, payload -> sentPayloads.add(payload) } + ) + + private fun fingerprint(index: Int): String = + index.toString(HEX_RADIX) + .padStart(FINGERPRINT_HEX_LENGTH, FINGERPRINT_PAD_CHAR) + .takeLast(FINGERPRINT_HEX_LENGTH) + + private fun ByteArray.toHex(): String = joinToString(HEX_SEPARATOR) { + HEX_BYTE_FORMAT.format(it.toInt() and BYTE_MASK) + } + + companion object { + private const val PREFS_PREFIX = "vouch-coordinator-" + private const val PEER_ID = "peer-id" + private const val PEER_NICKNAME = "peer" + private const val PEER_FINGERPRINT_INDEX = 1 + private const val VOUCHEE_FINGERPRINT_INDEX = 2 + private const val SINGLE_ATTESTATION_COUNT = 1 + private const val BEFORE_INTERVAL_DELTA_MS = 1L + private const val TEST_NOW_MS = 1_700_000_000_000L + private const val HEX_RADIX = 16 + private const val FINGERPRINT_HEX_LENGTH = VouchAttestation.FINGERPRINT_SIZE * 2 + private const val FINGERPRINT_PAD_CHAR = '0' + private const val HEX_SEPARATOR = "" + private const val HEX_BYTE_FORMAT = "%02x" + private const val BYTE_MASK = 0xFF + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/BridgeProtocolInteropTest.kt b/app/src/test/kotlin/com/bitchat/android/model/BridgeProtocolInteropTest.kt new file mode 100644 index 00000000..5fa39643 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/BridgeProtocolInteropTest.kt @@ -0,0 +1,163 @@ +package com.bitchat.android.model + +import com.bitchat.android.noise.CourierNoiseCrypto +import com.bitchat.android.nostr.MeshMessageIdentity +import com.bitchat.android.nostr.NostrIdentity +import com.bitchat.android.nostr.NostrKind +import com.bitchat.android.nostr.NostrProtocol +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BridgeProtocolInteropTest { + @Test + fun `carrier TLVs match the iOS wire fixture`() { + val carrier = NostrCarrierPacket( + direction = NostrCarrierPacket.Direction.TO_BRIDGE, + geohash = "u4pruy", + eventJson = "{}".toByteArray() + ) + + assertEquals( + "010001030200067534707275790300027b7d", + carrier.encode().toHex() + ) + assertEquals(carrier, NostrCarrierPacket.decode(carrier.encode())) + assertNull(NostrCarrierPacket.decode(carrier.encode().dropLast(1).toByteArray())) + } + + @Test + fun `carrier decoder skips unknown TLVs`() { + val encoded = NostrCarrierPacket( + NostrCarrierPacket.Direction.FROM_BRIDGE, + "u4pruy", + "{}".toByteArray() + ).encode() + val withUnknown = encoded + byteArrayOf(0x7F, 0x00, 0x02, 0x12, 0x34) + + assertEquals( + NostrCarrierPacket.Direction.FROM_BRIDGE, + NostrCarrierPacket.decode(withUnknown)?.direction + ) + } + + @Test + fun `courier envelope and daily tag match iOS fixtures`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 0x0102_0304_0506_0708uL, + ciphertext = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), + copies = 3u, + prekeyID = 0x89AB_CDEFu + ) + + assertEquals( + "010010000102030405060708090a0b0c0d0e0f" + + "0200080102030405060708" + + "030002aabb04000103" + + "05000489abcdef", + envelope.encode()!!.toHex() + ) + assertEquals(envelope, CourierEnvelope.decode(envelope.encode()!!)) + assertEquals( + "f7b87836e588a2b31b306605b3313744", + CourierEnvelope.recipientTag(ByteArray(32) { it.toByte() }, 1u).toHex() + ) + } + + @Test + fun `signed prekey canonical bytes match iOS fixture`() { + val bundle = PrekeyBundle( + noiseStaticPublicKey = ByteArray(32) { 0x11 }, + prekeys = listOf( + PrekeyBundle.Prekey(0x0102_0304, ByteArray(32) { 0x22 }) + ), + generatedAt = 0x0102_0304_0506_0708L, + signature = ByteArray(64) { 0x33 } + ) + + assertEquals( + "18" + + "626974636861742d7072656b65792d62756e646c652d7631" + + "11".repeat(32) + + "01" + + "01020304" + + "22".repeat(32) + + "0102030405060708", + bundle.signableBytes().toHex() + ) + assertEquals(bundle, PrekeyBundle.decode(bundle.encode()!!)) + } + + @Test + fun `courier Noise X opens static and one-time prekey ciphertexts`() { + val senderPrivate = ByteArray(32) { (it + 1).toByte() } + val recipientPrivate = ByteArray(32) { (it + 33).toByte() } + val payload = "offline hello".toByteArray() + + val staticCiphertext = CourierNoiseCrypto.seal( + payload, + senderPrivate, + CourierNoiseCrypto.publicKey(recipientPrivate) + ) + val staticOpened = CourierNoiseCrypto.open(staticCiphertext, recipientPrivate) + assertArrayEquals(payload, staticOpened.payload) + assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), staticOpened.senderStaticKey) + + val prekey = PrekeyBundle.Prekey( + id = 0x89AB_CDEFL, + publicKey = CourierNoiseCrypto.publicKey(recipientPrivate) + ) + val prekeyCiphertext = CourierNoiseCrypto.sealToPrekey(payload, senderPrivate, prekey) + val prekeyOpened = CourierNoiseCrypto.openWithPrekey( + prekeyCiphertext, + recipientPrivate, + prekey.id + ) + assertArrayEquals(payload, prekeyOpened.payload) + assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), prekeyOpened.senderStaticKey) + } + + @Test + fun `public message stable identity matches cross-language fixture`() { + val id = MeshMessageIdentity.stableId( + senderIdHex = "0011223344556677", + timestampMs = 1_750_000_000_123, + content = "hello mesh" + ) + + assertEquals("b83f94d81dcdd1b0c0048f6645995dd4", id) + assertTrue(id.all { it in '0'..'9' || it in 'a'..'f' }) + } + + @Test + fun `bridge Nostr event uses signed rendezvous tags`() { + val identity = NostrIdentity.fromPrivateKey("01".padStart(64, '0')) + val event = NostrProtocol.createBridgeMeshEvent( + content = "hello mesh", + cell = "u4pruy", + senderIdentity = identity, + nickname = "alice", + meshSenderId = "0011223344556677", + meshTimestampMs = 1_750_000_000_123 + ) + + assertEquals(NostrKind.EPHEMERAL_EVENT, event.kind) + assertEquals(listOf("r", "u4pruy"), event.tags[0]) + assertEquals(listOf("n", "alice"), event.tags[1]) + assertEquals( + listOf( + "m", + "b83f94d81dcdd1b0c0048f6645995dd4", + "0011223344556677", + "1750000000123" + ), + event.tags[2] + ) + assertTrue(event.isValidSignature()) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt b/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt new file mode 100644 index 00000000..bf01a53a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CourierEnvelopeTest { + @Test + fun `encoding matches fixed iOS wire vector`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 0x0102030405060708u, + ciphertext = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte()), + copies = 4u, + prekeyID = 0x11223344u + ) + assertEquals( + "010010000102030405060708090a0b0c0d0e0f" + + "0200080102030405060708" + + "030003aabbcc" + + "04000104" + + "05000411223344", + envelope.encode()!!.toHex() + ) + } + + @Test + fun `copies TLV round trips and legacy omission defaults to one`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 123456789u, + ciphertext = ByteArray(96) { (it + 1).toByte() }, + copies = 4u + ) + val decoded = CourierEnvelope.decode(envelope.encode()!!)!! + assertEquals(4u.toUByte(), decoded.copies) + assertArrayEquals(envelope.recipientTag, decoded.recipientTag) + assertArrayEquals(envelope.ciphertext, decoded.ciphertext) + + val legacy = envelope.copy(copies = 1u) + assertEquals(1u.toUByte(), CourierEnvelope.decode(legacy.encode()!!)!!.copies) + } + + @Test + fun `prekey id survives decode re-encode and copy changes`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 123456789u, + ciphertext = ByteArray(96) { (it + 1).toByte() }, + copies = 4u, + prekeyID = 0xfedcba98u + ) + + val decoded = CourierEnvelope.decode(envelope.encode()!!)!! + + assertEquals(0xfedcba98u, decoded.prekeyID) + assertArrayEquals(envelope.encode(), decoded.copy(copies = 4u).encode()) + assertEquals(0xfedcba98u, decoded.copy(copies = 2u).prekeyID) + } + + @Test + fun `invalid or duplicate prekey fields are rejected`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }, prekeyID = 7u) + val encoded = envelope.encode()!! + val prekeyField = encoded.copyOfRange(encoded.size - 7, encoded.size) + + assertNull(CourierEnvelope.decode(encoded + prekeyField)) + assertNull(CourierEnvelope.decode(encoded.copyOf(encoded.size - 1))) + } + + @Test + fun `tag rotates daily and matches adjacent day for clock skew`() { + val key = ByteArray(32) { 0x2a } + val now = 10L * 86_400_000L + val tag = CourierEnvelope.recipientTag(key, CourierEnvelope.epochDay(now) - 1u) + val envelope = CourierEnvelope(tag, (now + 1_000).toULong(), ByteArray(96) { 1 }) + assertEquals(true, envelope.matchesRecipient(key, now)) + assertEquals(false, envelope.matchesRecipient(ByteArray(32) { 0x2b }, now)) + } + + @Test + fun `oversized ciphertext is rejected`() { + val envelope = CourierEnvelope( + ByteArray(16), + 1u, + ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1) + ) + assertNull(envelope.encode()) + } + + @Test + fun `invalid copy budgets are rejected instead of normalized`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }) + assertNull(envelope.copy(copies = 0u).encode()) + assertNull(envelope.copy(copies = 9u).encode()) + + val encoded = envelope.copy(copies = 2u).encode()!! + encoded[encoded.lastIndex] = 0 + assertNull(CourierEnvelope.decode(encoded)) + } + + @Test + fun `duplicate required fields are rejected`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }) + val encoded = envelope.encode()!! + assertNull(CourierEnvelope.decode(encoded + encoded.copyOfRange(0, 19))) + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 892f6565..d56366cb 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -67,6 +67,18 @@ class IdentityAnnouncementTest { assertEquals(PeerCapabilities.NONE, decoded.capabilities) } + @Test + fun `oversized bridge cell is omitted without dropping announcement`() { + val encoded = IdentityAnnouncement( + nickname, + noiseKey, + signingKey, + bridgeGeohash = "u".repeat(13) + ).encode()!! + + assertNull(IdentityAnnouncement.decode(encoded)?.bridgeGeohash) + } + @Test fun `unknown capability bits and TLVs survive decode and re-encode`() { val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! @@ -88,18 +100,18 @@ class IdentityAnnouncementTest { } @Test - fun `local announcement send advertises private media`() { + fun `local announcement send advertises private media and groups`() { + PeerCapabilities.setPhoneFeaturesEnabled(true) val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! assertArrayEquals( - byteArrayOf(0x05, 0x02, 0x00, 0x01), + byteArrayOf(0x05, 0x02, 0x79, 0x03), 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)) + assertTrue(capabilities.contains(PeerCapabilities.VOUCH)) + assertTrue(capabilities.contains(PeerCapabilities.PREKEYS)) } } diff --git a/app/src/test/kotlin/com/bitchat/android/model/PrivateMediaMessageIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/model/PrivateMediaMessageIdentityTest.kt new file mode 100644 index 00000000..846d3da1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/PrivateMediaMessageIdentityTest.kt @@ -0,0 +1,34 @@ +package com.bitchat.android.model + +import org.junit.Assert.* +import org.junit.Test + +class PrivateMediaMessageIdentityTest { + private val sender = "0011223344556677" + private val recipient = "8899aabbccddeeff" + + @Test + fun `matches the iOS version one golden vector`() { + assertEquals("media-910bd42c65060ab76bb6406f220c4516", + PrivateMediaMessageIdentity.stableID(sender, recipient, + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg")) + } + + @Test + fun `retries keep identity while direction and name changes do not collide`() { + val name = "voice_0011223344556677.m4a" + val id = PrivateMediaMessageIdentity.stableID(sender, recipient, name) + assertNotNull(id) + assertTrue(PrivateMediaMessageIdentity.isStableID(id!!)) + assertNotEquals(id, PrivateMediaMessageIdentity.stableID(recipient, sender, name)) + assertNotEquals(id, PrivateMediaMessageIdentity.stableID(sender, recipient, "voice_0011223344556678.m4a")) + } + + @Test + fun `ordinary names and paths do not acquire receipt identities`() { + listOf("voice_20260908.m4a", "img_20260908.jpg", "../voice_0011223344556677.m4a", + "voice_0011223344556677.mp3", "photo.png").forEach { + assertNull(PrivateMediaMessageIdentity.stableID(sender, recipient, it)) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt b/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt new file mode 100644 index 00000000..0ca771c8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class Tlv16CodecTest { + @Test + fun `codec preserves ordered fields and unknown types`() { + val encoded = requireNotNull( + Tlv16Codec.encode( + Tlv16Codec.Field(1, byteArrayOf(1, 2)), + Tlv16Codec.Field(0x7F, byteArrayOf(3)) + ) + ) + + val decoded = requireNotNull(Tlv16Codec.decode(encoded)) + assertEquals(listOf(1, 0x7F), decoded.map { it.type }) + assertArrayEquals(byteArrayOf(1, 2), decoded[0].value) + assertArrayEquals(byteArrayOf(3), decoded[1].value) + } + + @Test + fun `codec rejects truncated framing and oversized values`() { + assertNull(Tlv16Codec.decode(byteArrayOf(1, 0))) + assertNull(Tlv16Codec.decode(byteArrayOf(1, 0, 2, 1))) + assertNull(Tlv16Codec.encode(Tlv16Codec.Field(1, ByteArray(0x1_0000)))) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/VouchAttestationTest.kt b/app/src/test/kotlin/com/bitchat/android/model/VouchAttestationTest.kt new file mode 100644 index 00000000..d351d345 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/VouchAttestationTest.kt @@ -0,0 +1,89 @@ +package com.bitchat.android.model + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +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.assertTrue +import org.junit.Test +import java.security.SecureRandom + +class VouchAttestationTest { + private val privateKey = Ed25519PrivateKeyParameters(SecureRandom()) + private val publicKey: Ed25519PublicKeyParameters = privateKey.generatePublicKey() + private val fingerprint = ByteArray(VouchAttestation.FINGERPRINT_SIZE) { FINGERPRINT_BYTE } + private val voucheeKey = ByteArray(VouchAttestation.SIGNING_KEY_SIZE) { SIGNING_KEY_BYTE } + + @Test + fun `attestation round trips with iOS wire format and verifies`() { + val attestation = buildAttestation() + val decoded = VouchAttestation.decode(attestation.encode())!! + + assertEquals(attestation, decoded) + assertTrue(verify(decoded.signature, decoded.signableBytes(), publicKey.encoded)) + assertArrayEquals(fingerprint, decoded.voucheeFingerprint) + } + + @Test + fun `unknown TLV is skipped but truncation is rejected`() { + val encoded = buildAttestation().encode() + val withUnknown = encoded + byteArrayOf(UNKNOWN_TLV_TYPE, UNKNOWN_TLV_LENGTH, UNKNOWN_TLV_VALUE) + + assertEquals(buildAttestation(), VouchAttestation.decode(withUnknown)) + assertEquals(null, VouchAttestation.decode(encoded.copyOf(encoded.size - TRUNCATED_BYTE_COUNT))) + } + + @Test + fun `tampering and expiry are rejected`() { + val attestation = buildAttestation() + val tampered = attestation.signableBytes().copyOf().also { + it[it.lastIndex] = (it.last().toInt() xor TAMPER_MASK).toByte() + } + assertFalse(verify(attestation.signature, tampered, publicKey.encoded)) + assertTrue(attestation.isExpired(TEST_TIMESTAMP_MS + VouchAttestation.MAX_AGE_MS + EXPIRY_DELTA_MS)) + assertTrue(attestation.isExpired(TEST_TIMESTAMP_MS - VouchAttestation.MAX_CLOCK_SKEW_MS - EXPIRY_DELTA_MS)) + } + + @Test + fun `batch caps encoding and decoding`() { + val attestations = List(VouchAttestation.MAX_BATCH_COUNT) { buildAttestation() } + val encoded = VouchAttestation.encodeList(attestations)!! + assertEquals(VouchAttestation.MAX_BATCH_COUNT, VouchAttestation.decodeList(encoded).size) + assertEquals(null, VouchAttestation.encodeList(attestations + buildAttestation())) + + val dishonestCount = encoded.copyOf().also { it[BATCH_COUNT_OFFSET] = UByte.MAX_VALUE.toByte() } + assertEquals(VouchAttestation.MAX_BATCH_COUNT, VouchAttestation.decodeList(dishonestCount).size) + } + + private fun buildAttestation(): VouchAttestation = + requireNotNull(VouchAttestation.build(fingerprint, voucheeKey, TEST_TIMESTAMP_MS, ::sign)) + + private fun sign(data: ByteArray): ByteArray { + val signer = Ed25519Signer() + signer.init(true, privateKey) + signer.update(data, 0, data.size) + return signer.generateSignature() + } + + private fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + val verifier = Ed25519Signer() + verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0)) + verifier.update(data, 0, data.size) + return verifier.verifySignature(signature) + } + + companion object { + private const val TEST_TIMESTAMP_MS = 1_700_000_000_000L + private const val FINGERPRINT_BYTE: Byte = 0x11 + private const val SIGNING_KEY_BYTE: Byte = 0x22 + private const val UNKNOWN_TLV_TYPE: Byte = 0x7F + private const val UNKNOWN_TLV_LENGTH: Byte = 0x01 + private const val UNKNOWN_TLV_VALUE: Byte = 0x42 + private const val TRUNCATED_BYTE_COUNT = 1 + private const val TAMPER_MASK = 0x01 + private const val EXPIRY_DELTA_MS = 1L + private const val BATCH_COUNT_OFFSET = 0 + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt new file mode 100644 index 00000000..3d887ab1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt @@ -0,0 +1,240 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class BridgeCourierServiceTest { + + private val now = 1_750_000_000_000L + private val relays = listOf("wss://bridge-test.invalid") + private val recipientKey = ByteArray(32) { 0x32 } + + @Test + fun `deposit creates a signed iOS-compatible event and receiver admits it once`() { + val senderRelay = FakeRelay() + val sender = BridgeCourierService( + cipher = FakeCipher(ByteArray(32) { 0x11 }), + onEnvelope = {}, + relayManager = senderRelay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-sender") } + ) + var accepted = false + + val event = try { + assertTrue(sender.deposit("synthetic bridge payload", "bridge-message", recipientKey) { + accepted = true + }) + assertTrue(accepted) + assertEquals(1, senderRelay.sentEvents.size) + + senderRelay.sentEvents.single().also { + assertEquals(1401, it.kind) + assertTrue(it.isValidSignature()) + assertEquals(relays, senderRelay.lastSendRelays) + } + } finally { + sender.stop() + } + + val encoded = Base64.getDecoder().decode(event.content) + val envelope = requireNotNull(CourierEnvelope.decode(encoded)) + assertTrue(envelope.matchesRecipient(recipientKey, now)) + assertEquals(1u.toUByte(), envelope.copies) + assertEquals( + listOf("x", envelope.recipientTag.toHex()), + event.tags.first { it.firstOrNull() == "x" } + ) + assertEquals( + listOf("expiration", (envelope.expiry / 1000u).toString()), + event.tags.first { it.firstOrNull() == "expiration" } + ) + val typed = requireNotNull(NoisePayload.decode(envelope.ciphertext)) + assertEquals(NoisePayloadType.PRIVATE_MESSAGE, typed.type) + val privateMessage = requireNotNull(PrivateMessagePacket.decode(typed.data)) + assertEquals("bridge-message", privateMessage.messageID) + assertEquals("synthetic bridge payload", privateMessage.content) + + val receiverRelay = FakeRelay() + val received = mutableListOf() + val receiver = BridgeCourierService( + cipher = FakeCipher(recipientKey), + onEnvelope = received::add, + relayManager = receiverRelay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") } + ) + try { + receiver.start() + assertNotNull(receiverRelay.subscription) + assertTrue(receiverRelay.subscription!!.filter.matches(event)) + assertEquals(relays, receiverRelay.subscription!!.relayUrls) + + receiverRelay.emit(event) + receiverRelay.emit(event) + + assertEquals(listOf(envelope), received) + } finally { + receiver.stop() + } + assertEquals(1, receiverRelay.unsubscribedIDs.size) + } + + @Test + fun `receiver rejects a validly signed event with mismatched metadata and deposit fails closed`() { + val relay = FakeRelay() + val received = mutableListOf() + val receiver = BridgeCourierService( + cipher = FakeCipher(recipientKey), + onEnvelope = received::add, + relayManager = relay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") } + ) + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now)), + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = byteArrayOf(1, 2, 3), + copies = 1u + ) + val identity = NostrIdentity.fromSeed("bridge-courier-invalid-event") + val mismatchedTag = identity.signEvent( + NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (now / 1000).toInt(), + kind = 1401, + tags = listOf( + listOf("x", "00".repeat(CourierEnvelope.TAG_LENGTH)), + listOf("expiration", (envelope.expiry / 1000u).toString()) + ), + content = Base64.getEncoder().encodeToString(requireNotNull(envelope.encode())) + ) + ) + try { + receiver.start() + relay.emit(mismatchedTag) + assertTrue(received.isEmpty()) + + val oversizedSender = BridgeCourierService( + cipher = FakeCipher(ByteArray(32) { 0x21 }, ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1)), + onEnvelope = {}, + relayManager = relay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-oversized") } + ) + try { + assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey)) + + relay.connected = false + assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey)) + } finally { + oversizedSender.stop() + } + } finally { + receiver.stop() + } + } + + @Test + fun `deposit completion waits for relay acceptance`() { + val relay = FakeRelay().apply { acceptImmediately = false } + val sender = BridgeCourierService( + cipher = FakeCipher(ByteArray(32) { 0x11 }), + onEnvelope = {}, + relayManager = relay, + relayUrls = relays, + clock = { now }, + identityFactory = { NostrIdentity.fromSeed("bridge-courier-pending") } + ) + var accepted = 0 + + try { + assertTrue(sender.depositPayload(byteArrayOf(1, 2, 3), recipientKey) { accepted++ }) + assertEquals(0, accepted) + + relay.acceptPendingEvent() + assertEquals(1, accepted) + } finally { + sender.stop() + } + } + + private class FakeCipher( + private val staticKey: ByteArray, + private val sealedOverride: ByteArray? = null + ) : BridgeCourierCipher { + override fun staticPublicKey(): ByteArray = staticKey + + override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray = + sealedOverride ?: payload.copyOf() + } + + private class FakeRelay : BridgeCourierRelay { + data class Subscription( + val filter: NostrFilter, + val id: String, + val relayUrls: List, + val handler: (NostrEvent) -> Unit + ) + + var connected = true + var acceptImmediately = true + var subscription: Subscription? = null + val sentEvents = mutableListOf() + var lastSendRelays: List = emptyList() + val unsubscribedIDs = mutableListOf() + private var pendingAccepted: (() -> Unit)? = null + + override fun subscribe( + filter: NostrFilter, + id: String, + targetRelayUrls: List, + handler: (NostrEvent) -> Unit + ) { + subscription = Subscription(filter, id, targetRelayUrls, handler) + } + + override fun unsubscribe(id: String) { + unsubscribedIDs += id + if (subscription?.id == id) subscription = null + } + + override fun hasConnectedRelay(relayUrls: Collection): Boolean = connected + + override fun sendEvent( + event: NostrEvent, + relayUrls: List, + onAccepted: () -> Unit + ): Boolean { + if (!connected) return false + sentEvents += event + lastSendRelays = relayUrls + if (acceptImmediately) onAccepted() else pendingAccepted = onAccepted + return true + } + + fun acceptPendingEvent() { + val callback = requireNotNull(pendingAccepted) + pendingAccepted = null + callback() + } + + fun emit(event: NostrEvent) { + subscription?.handler?.invoke(event) + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/CustomRelayUrlTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/CustomRelayUrlTest.kt new file mode 100644 index 00000000..e1a17987 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/CustomRelayUrlTest.kt @@ -0,0 +1,18 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.* +import org.junit.Test + +class CustomRelayUrlTest { + @Test fun `normalizes secure relay without dropping path or port`() { + assertEquals("wss://relay.example", CustomRelayUrl.normalize(" WSS://RELAY.EXAMPLE/ ")) + assertEquals("wss://relay.example:8443/nostr", CustomRelayUrl.normalize("wss://relay.example:8443/nostr")) + } + @Test fun `rejects insecure credentials queries and invalid endpoints`() { + listOf("ws://relay.example", "https://relay.example", "wss://user:secret@relay.example", + "wss://relay.example?secret=x", "wss://relay.example#fragment", "wss://relay.example:0", + "wss://relay.example:65536", "wss:///nostr", "not a URL").forEach { + assertNull(it, CustomRelayUrl.normalize(it)) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt index 8954cbe3..37747a38 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -68,6 +68,31 @@ class NostrPendingEventQueueTest { ) } + @Test + fun `revocation invalidates queued and already selected deliveries even after reenable`() { + val queue = NostrPendingEventQueue(4) + var generation = 1 + val captured = generation + queue.enqueue(event("bridge"), listOf("relay"), null) { generation == captured } + val selected = queue.pendingForRelay("relay").single() + generation++ // Disable. + generation++ // Re-enable; old permission must remain invalid. + org.junit.Assert.assertFalse(selected.publicationAllowed()) + assertEquals(emptyList(), queue.pendingForRelay("relay")) + assertEquals(0, queue.size()) + } + + @Test + fun `removing a relay drops its pending work without losing other deliveries`() { + val queue = NostrPendingEventQueue(4) + queue.enqueue(event("shared"), listOf("removed", "retained"), null) + queue.enqueue(event("removed-only"), listOf("removed"), null) + queue.removeRelay("removed") + assertEquals(0, queue.pendingForRelay("removed").size) + assertEquals(listOf("shared"), queue.pendingForRelay("retained").map { it.event.content }) + assertEquals(1, queue.size()) + } + private fun event(content: String): NostrEvent { val privateKey = "0".repeat(63) + "1" return NostrEvent( diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index 5faae233..20be9499 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -5,6 +5,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import kotlinx.coroutines.runBlocking class NostrProtocolTest { private val gson = Gson() @@ -42,6 +43,54 @@ class NostrProtocolTest { assertNull(decrypted) } + @Test + fun createGeohashTextNote_addsExpirationAndUrgentTags() = runBlocking { + val identity = NostrIdentity.generate() + + val event = NostrProtocol.createGeohashTextNote( + content = "road closed", + geohash = "u33dc", + senderIdentity = identity, + nickname = "alice", + expiresAt = 1_700_086_400, + urgent = true + ) + + assertEquals(NostrKind.TEXT_NOTE, event.kind) + assertTrue(event.tags.contains(listOf("g", "u33dc"))) + assertTrue(event.tags.contains(listOf("n", "alice"))) + assertTrue(event.tags.contains(listOf("expiration", "1700086400"))) + assertTrue(event.tags.contains(listOf("t", "urgent"))) + assertTrue(event.isValidSignature()) + } + + @Test + fun createDeleteEvent_isSignedNip09Request() = runBlocking { + val identity = NostrIdentity.generate() + + val event = NostrProtocol.createDeleteEvent("event-id", identity) + + assertEquals(NostrKind.DELETION, event.kind) + assertEquals(listOf(listOf("e", "event-id")), event.tags) + assertTrue(event.isValidSignature()) + } + + @Test + fun rejectsMalformedAuthenticatedEnvelopes() { + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val malformed = listOf( + forgedGiftWrap("hello", sender, sender, recipient, rumorKind = NostrKind.TEXT_NOTE), + forgedGiftWrap("hello", sender, sender, recipient, rumorTags = listOf(listOf("p", sender.publicKeyHex))), + forgedGiftWrap("hello", sender, sender, recipient, sealTags = listOf(listOf("p", recipient.publicKeyHex))), + forgedGiftWrap("hello", sender, sender, recipient, rumorTimestamp = 1), + forgedGiftWrap("hello", sender, sender, recipient, outerTags = emptyList()) + ) + malformed.forEach { assertNull(NostrProtocol.decryptPrivateMessage(it, recipient)) } + val ios = forgedGiftWrap("hello", sender, sender, recipient, rumorTags = emptyList()) + assertEquals("hello", NostrProtocol.decryptPrivateMessage(ios, recipient)?.first) + } + @Test fun createPrivateMessage_reservesSlackInsideIosLookback() { val sender = NostrIdentity.generate() @@ -92,13 +141,18 @@ class NostrProtocolTest { content: String, claimedSender: NostrIdentity, sealSigner: NostrIdentity, - recipient: NostrIdentity + recipient: NostrIdentity, + rumorKind: Int = NostrKind.DIRECT_MESSAGE, + rumorTags: List> = listOf(listOf("p", recipient.publicKeyHex)), + sealTags: List> = emptyList(), + rumorTimestamp: Int = (System.currentTimeMillis() / 1000).toInt(), + outerTags: List> = listOf(listOf("p", recipient.publicKeyHex)) ): NostrEvent { val rumorBase = NostrEvent( pubkey = claimedSender.publicKeyHex, - createdAt = (System.currentTimeMillis() / 1000).toInt(), - kind = NostrKind.DIRECT_MESSAGE, - tags = listOf(listOf("p", recipient.publicKeyHex)), + createdAt = rumorTimestamp, + kind = rumorKind, + tags = rumorTags, content = content ) val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) @@ -111,7 +165,7 @@ class NostrProtocolTest { pubkey = sealSigner.publicKeyHex, createdAt = NostrCrypto.randomizeTimestampUpToPast(), kind = NostrKind.SEAL, - tags = emptyList(), + tags = sealTags, content = sealContent ).sign(sealSigner.privateKeyHex) @@ -125,7 +179,7 @@ class NostrProtocolTest { pubkey = wrapPublicKey, createdAt = NostrCrypto.randomizeTimestampUpToPast(), kind = NostrKind.GIFT_WRAP, - tags = listOf(listOf("p", recipient.publicKeyHex)), + tags = outerTags, content = giftWrapContent ).sign(wrapPrivateKey) } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt new file mode 100644 index 00000000..c1411956 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NostrTimestampPolicyTest { + + private val now = 1_700_000_000L + private val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + private val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS + private val giftWrapMax = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS + + @Test + fun `rumor timestamp at now is accepted`() { + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(now.toInt(), now)) + } + + @Test + fun `rumor timestamp within lookback is accepted`() { + val ts = (now - lookback).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `rumor timestamp just inside skew past lookback is accepted`() { + val ts = (now - lookback - skew).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `rumor timestamp older than lookback plus skew is rejected`() { + val ts = (now - lookback - skew - 1).toInt() + assertFalse(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated rumor within skew is accepted`() { + val ts = (now + skew).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated rumor beyond skew is rejected`() { + val ts = (now + skew + 1).toInt() + assertFalse(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated gift wrap beyond skew is rejected`() { + val createdAt = (now + skew + 1).toInt() + assertFalse(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } + + @Test + fun `gift wrap within randomization ceiling is accepted`() { + val createdAt = (now - giftWrapMax).toInt() + assertTrue(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } + + @Test + fun `gift wrap older than randomization ceiling is rejected`() { + val createdAt = (now - giftWrapMax - 1).toInt() + assertFalse(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/protocol/MeshPingPayloadTest.kt b/app/src/test/kotlin/com/bitchat/android/protocol/MeshPingPayloadTest.kt new file mode 100644 index 00000000..f41fdb28 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/protocol/MeshPingPayloadTest.kt @@ -0,0 +1,35 @@ +package com.bitchat.android.protocol + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MeshPingPayloadTest { + private val nonce = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + + @Test + fun `payload round trips with iOS wire layout`() { + val encoded = MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL).encode() + + assertEquals(MeshDiagnosticsConstants.PAYLOAD_SIZE, encoded.size) + assertArrayEquals(nonce, encoded.copyOfRange(0, MeshDiagnosticsConstants.NONCE_SIZE)) + assertEquals(MeshDiagnosticsConstants.TTL.toByte(), encoded.last()) + assertEquals(MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL), MeshPingPayload.decode(encoded)) + } + + @Test + fun `decoder rejects truncation and tolerates trailing bytes`() { + assertNull(MeshPingPayload.decode(ByteArray(MeshDiagnosticsConstants.PAYLOAD_SIZE - 1))) + val extended = MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL).encode() + byteArrayOf(99) + assertEquals(MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL), MeshPingPayload.decode(extended)) + } + + @Test + fun `hop count uses origin and received ttl`() { + val payload = MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL) + + assertEquals(1, payload.hopCount(MeshDiagnosticsConstants.TTL)) + assertEquals(3, payload.hopCount((MeshDiagnosticsConstants.TTL - 2u).toUByte())) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ChannelInvitationTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ChannelInvitationTest.kt new file mode 100644 index 00000000..28822e63 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ChannelInvitationTest.kt @@ -0,0 +1,16 @@ +package com.bitchat.android.services + +import org.junit.Assert.* +import org.junit.Test + +class ChannelInvitationTest { + @Test fun `explicit synthetic cell round trips without location access`() { + assertEquals("s000", ChannelInvitation.decode(ChannelInvitation.link("s000")!!)) + } + @Test fun `rejects ambiguous or malformed invitations`() { + listOf("https://geohash/s000", "bitchat://geohash/s000?live=1", + "bitchat://geohash/s000#extra", "bitchat://user@geohash/s000", + "bitchat://geohash:42/s000", "bitchat://geohash/", "bitchat://geohash/invalid", + "bitchat://geohash/s000/extra").forEach { assertNull(it, ChannelInvitation.decode(it)) } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt index e16996f1..7ff3ec63 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt @@ -6,6 +6,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -56,4 +57,16 @@ class ContactDirectoryTest { assertNull(resolution.displayName) } + + @Test + fun `offline contact resolves a fingerprint-validated cached Noise key`() { + val noiseKey = ByteArray(32) { it.toByte() } + val fingerprint = ContactIdentityResolver.fingerprintHex(noiseKey) + val peerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey) + identityManager.cachePeerNoiseKey(peerID, ContactIdentityResolver.noiseKeyHex(noiseKey)) + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertTrue(resolution.noisePublicKey!!.contentEquals(noiseKey)) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt index 0d5947c9..1c29c86e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -38,6 +38,42 @@ class ConversationRepositoryTest { context.deleteDatabase(databaseName) } + @Test + fun `text receipts require matching durable content and survive deletion`() = runBlocking { + repository = ConversationRepository(context, dispatcher, databaseName, InMemoryConversationStorageCipher()) + val message = BitchatMessage(id = "synthetic-receipt", sender = "alice", content = "durable text", + timestamp = Date(100), isPrivate = true, senderPeerID = "peer-alice") + org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message)) + assertTrue(repository.upsertMessageAndWait("peer-alice", setOf("peer-alice"), "alice", message, false)) + assertTrue(repository.hasPrivateTextReceipt(message)) + org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message.copy(senderPeerID = "peer-other"))) + org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message.copy(content = "replacement"))) + repository.deleteMessage(message.id) + repository.awaitPendingWrites() + assertTrue(repository.hasPrivateTextReceipt(message)) + } + + @Test + fun `stable media is acknowledged only while durable or explicitly deleted`() = runBlocking { + val cipher = InMemoryConversationStorageCipher() + repository = ConversationRepository(context, dispatcher, databaseName, cipher) + val id = "media-00112233445566778899aabbccddeeff" + val payload = java.io.File(context.filesDir, "synthetic-media.m4a").apply { writeBytes(byteArrayOf(1, 2)) } + val message = BitchatMessage(id = id, sender = "alice", content = payload.absolutePath, + type = com.bitchat.android.model.BitchatMessageType.Audio, timestamp = Date(100), isPrivate = true) + assertEquals(PrivateMediaReceiptState.ABSENT, repository.privateMediaReceiptState(id)) + assertTrue(repository.upsertMessageAndWait("peer-alice", setOf("peer-alice"), "alice", message, false)) + assertEquals(PrivateMediaReceiptState.ACCEPTED, repository.privateMediaReceiptState(id)) + payload.delete() + assertEquals(PrivateMediaReceiptState.UNAVAILABLE, repository.privateMediaReceiptState(id)) + repository.deleteMessage(id) + repository.awaitPendingWrites() + assertEquals(PrivateMediaReceiptState.TOMBSTONED, repository.privateMediaReceiptState(id)) + repository.closeForTest() + repository = ConversationRepository(context, dispatcher, databaseName, cipher) + assertEquals(PrivateMediaReceiptState.TOMBSTONED, repository.privateMediaReceiptState(id)) + } + @Test fun `reload restores persisted history after initial process restore`() { repository = ConversationRepository( diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt new file mode 100644 index 00000000..ded30b31 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.services + +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageOutboxStoreTest { + @Test + fun `sealed outbox round trips and wrong key fails closed`() { + val context = RuntimeEnvironment.getApplication() + val file = File(context.filesDir, "message-outbox.sealed") + file.delete() + val store = MessageOutboxStore(context, TestCipher(0x33)) + val entry = MessageOutboxStore.Entry("secret", "peer", "message", 100) + entry.depositedCourierKeys += "courier" + store.save(mapOf("conversation" to listOf(entry))) + + assertFalse(file.readBytes().toString(Charsets.UTF_8).contains("secret")) + assertEquals("secret", MessageOutboxStore(context, TestCipher(0x33)).load()["conversation"]?.single()?.content) + assertTrue(MessageOutboxStore(context, TestCipher(0x44)).load().isEmpty()) + } + + @Test + fun `wipe removes file and destroys key`() { + val context = RuntimeEnvironment.getApplication() + val cipher = TestCipher(0x33) + val store = MessageOutboxStore(context, cipher) + store.save(mapOf("conversation" to listOf(MessageOutboxStore.Entry("secret", "peer", "message", 100)))) + store.wipe() + + assertFalse(File(context.filesDir, "message-outbox.sealed").exists()) + assertTrue(cipher.destroyed) + } + + private class TestCipher(private val mask: Int) : ConversationStorageCipher { + var destroyed = false + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = byteArrayOf(mask.toByte()) + + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray { + require(envelope.firstOrNull() == mask.toByte()) + return envelope.drop(1).map { (it.toInt() xor mask).toByte() }.toByteArray() + } + override fun destroyKey() { destroyed = true } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt index 48cbe2da..19cee52c 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -14,6 +14,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -36,6 +37,7 @@ class MessageRouterTest { private lateinit var mesh: MeshService private lateinit var router: MessageRouter + private lateinit var identityManager: SecureIdentityStateManager private var fakeTime = 1_000_000L private val expired = mutableListOf() @@ -46,7 +48,7 @@ class MessageRouterTest { "message-router-test-${UUID.randomUUID()}", Context.MODE_PRIVATE ) - val identityManager = SecureIdentityStateManager(prefs, testOnly = true) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) ContactDirectory.identityManagerProvider = { identityManager } mesh = mock() @@ -56,11 +58,19 @@ class MessageRouterTest { ContactDirectory.initialize(context) { mesh } MessageRouter.disableSchedulerForTesting = true + MessageRouter.outboxStoreFactory = { testContext -> + MessageOutboxStore(testContext, object : ConversationStorageCipher { + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = plaintext + override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = envelope + override fun destroyKey() = Unit + }) + } MessageRouter.resetForTesting() fakeTime = 1_000_000L expired.clear() router = MessageRouter.getInstance(context, mesh) + router.clearAll() router.clock = { fakeTime } router.onMessageExpired = { expired.add(it) } } @@ -69,6 +79,7 @@ class MessageRouterTest { fun tearDown() { MessageRouter.resetForTesting() MessageRouter.disableSchedulerForTesting = false + MessageRouter.outboxStoreFactory = ::MessageOutboxStore ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } } @@ -174,6 +185,159 @@ class MessageRouterTest { verify(mesh, never()).initiateNoiseHandshake(any()) } + @Test + fun `direct send remains retained until delivery acknowledgement`() { + peerReady() + router.sendPrivate("direct", peerID, "peer", "msg-direct") + clearInvocations(mesh) + + fakeTime += 31_000 + router.tickOutbox() + verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct") + + router.onMessageAcknowledged("msg-direct", peerID) + clearInvocations(mesh) + router.tickOutbox() + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `queued message survives router recreation`() { + peerOffline() + router.sendPrivate("durable", peerID, "peer", "msg-durable") + + MessageRouter.resetForTesting() + router = MessageRouter.getInstance(RuntimeEnvironment.getApplication(), mesh) + router.clock = { fakeTime } + peerReady() + router.onSessionEstablished(peerID) + + verify(mesh).sendPrivateMessage("durable", peerID, "peer", "msg-durable") + } + + @Test + fun `offline cached contact deposits once with a verified peer courier`() { + val recipientNoiseKey = ByteArray(32) { 0x2A } + val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey) + val courierPeerID = "9999aaaabbbbcccc" + val courierNoiseKey = ByteArray(32) { 0x3B } + identityManager.cachePeerNoiseKey( + recipientPeerID, + ContactIdentityResolver.noiseKeyHex(recipientNoiseKey) + ) + whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn( + PeerInfo( + id = recipientPeerID, + nickname = "offline contact", + isConnected = false, + isDirectConnection = false, + noisePublicKey = recipientNoiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = fakeTime + ) + ) + whenever(mesh.getPeerInfos()).thenReturn( + listOf( + PeerInfo( + id = courierPeerID, + nickname = "verified courier", + isConnected = true, + isDirectConnection = true, + noisePublicKey = courierNoiseKey, + signingPublicKey = ByteArray(32) { 0x0C }, + isVerifiedNickname = false, + lastSeen = fakeTime, + hasVerifiedAnnouncement = true + ) + ) + ) + whenever(mesh.sendCourierMessage(any(), any(), any(), any())).thenReturn(listOf(courierPeerID)) + + val result = router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier") + + assertEquals(MessageRouter.RouteResult.QUEUED, result) + verify(mesh).sendCourierMessage( + eq("courier payload"), + eq("msg-courier"), + argThat { contentEquals(recipientNoiseKey) }, + eq(listOf(courierPeerID)) + ) + + router.tickOutbox() + + verify(mesh, times(1)).sendCourierMessage( + eq("courier payload"), + eq("msg-courier"), + argThat { contentEquals(recipientNoiseKey) }, + eq(listOf(courierPeerID)) + ) + } + + @Test + fun `courier acknowledgement clears an offline contact outbox entry before direct reconnect`() { + val recipientNoiseKey = ByteArray(32) { 0x4A } + val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey) + identityManager.cachePeerNoiseKey( + recipientPeerID, + ContactIdentityResolver.noiseKeyHex(recipientNoiseKey) + ) + whenever(mesh.getPeerNicknames()).thenReturn(mapOf(recipientPeerID to "offline contact")) + whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn( + PeerInfo( + id = recipientPeerID, + nickname = "offline contact", + isConnected = false, + isDirectConnection = false, + noisePublicKey = recipientNoiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = fakeTime + ) + ) + + assertEquals( + MessageRouter.RouteResult.QUEUED, + router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier-ack") + ) + router.onMessageAcknowledged("msg-courier-ack", recipientPeerID) + + whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn( + PeerInfo( + id = recipientPeerID, + nickname = "offline contact", + isConnected = true, + isDirectConnection = true, + noisePublicKey = recipientNoiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = fakeTime + ) + ) + whenever(mesh.hasEstablishedSession(recipientPeerID)).thenReturn(true) + + router.tickOutbox() + + verify(mesh, never()).sendPrivateMessage( + eq("courier payload"), + eq(recipientPeerID), + any(), + eq("msg-courier-ack") + ) + } + + @Test + fun `outbox stops transport retries after eight attempts`() { + peerReady() + router.sendPrivate("bounded", peerID, "peer", "msg-bounded") + repeat(10) { + fakeTime += 10 * 60_000L + 1 + router.tickOutbox() + } + + verify(mesh, times(8)).sendPrivateMessage("bounded", peerID, "peer", "msg-bounded") + } + @Test fun `scheduler stops with the mesh service and restarts on rebind`() { MessageRouter.disableSchedulerForTesting = false diff --git a/app/src/test/kotlin/com/bitchat/android/services/PrivateMediaOutboxStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/PrivateMediaOutboxStoreTest.kt new file mode 100644 index 00000000..c91ce0e7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/PrivateMediaOutboxStoreTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.services + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PrivateMediaOutboxStoreTest { + private val id = "media-00112233445566778899aabbccddeeff" + + @Test + fun `restart retains original payload and attempt count and wipe removes it`() { + val context = RuntimeEnvironment.getApplication() + File(context.filesDir, "private-media-outbox").deleteRecursively() + val cipher = InMemoryConversationStorageCipher() + val store = PrivateMediaOutboxStore(context, cipher) + val entry = PrivateMediaOutboxStore.Entry(id, "conversation", "0011223344556677", + "synthetic-payload", 100, attempts = 4, lastAttemptAt = 500) + store.save(entry) + assertEquals(listOf(entry), PrivateMediaOutboxStore(context, cipher).load()) + assertFalse(File(context.filesDir, "private-media-outbox/$id").readText().contains("synthetic-payload")) + store.wipe() + assertTrue(PrivateMediaOutboxStore(context, cipher).load().isEmpty()) + } + + @Test + fun `corrupt record is never retried or silently overwritten on load`() { + val context = RuntimeEnvironment.getApplication() + val directory = File(context.filesDir, "private-media-outbox").apply { mkdirs() } + val file = File(directory, id).apply { writeText("corrupt") } + assertTrue(PrivateMediaOutboxStore(context, InMemoryConversationStorageCipher()).load().isEmpty()) + assertTrue(file.exists()) + file.delete() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt new file mode 100644 index 00000000..7e24518d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt @@ -0,0 +1,70 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.hexEncodedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VerificationServiceTest { + private lateinit var encryptionService: EncryptionService + + @Before + fun setup() { + val context: Context = ApplicationProvider.getApplicationContext() + encryptionService = EncryptionService(context) + VerificationService.configure(encryptionService) + } + + @Test + fun `freshness check rejects both stale and future-dated timestamps`() { + assertEquals(Long.MAX_VALUE, verificationTimestampSkewSeconds(1_700_000_000L, Long.MIN_VALUE)) + assertEquals(Long.MAX_VALUE - 1_700_000_000L, verificationTimestampSkewSeconds(1_700_000_000L, Long.MAX_VALUE)) + assertEquals(0L, verificationTimestampSkewSeconds(1_700_000_000L, 1_700_000_000L)) + assertEquals(60L, verificationTimestampSkewSeconds(1_700_000_060L, 1_700_000_000L)) + assertEquals(3_600L, verificationTimestampSkewSeconds(1_700_000_000L, 1_700_003_600L)) + } + + @Test + fun `verifyScannedQR accepts a freshly signed payload`() { + val qr = VerificationService.buildMyQRString(nickname = "alice", npub = null) + assertNotNull(qr) + assertNotNull(VerificationService.verifyScannedQR(qr!!, maxAgeSeconds = 60)) + } + + @Test + fun `verifyScannedQR rejects a future-dated payload`() { + val qr = signedQr(ts = (System.currentTimeMillis() / 1000L) + 3_600L) + assertNull(VerificationService.verifyScannedQR(qr, maxAgeSeconds = 60)) + } + + @Test + fun `verifyScannedQR rejects an expired payload`() { + val qr = signedQr(ts = (System.currentTimeMillis() / 1000L) - 3_600L) + assertNull(VerificationService.verifyScannedQR(qr, maxAgeSeconds = 60)) + } + + private fun signedQr(ts: Long): String { + val noise = encryptionService.getStaticPublicKey()!!.joinToString("") { "%02x".format(it) } + val sign = encryptionService.getSigningPublicKey()!!.joinToString("") { "%02x".format(it) } + val payload = VerificationService.VerificationQR( + v = 1, + noiseKeyHex = noise, + signKeyHex = sign, + npub = null, + nickname = "future-alice", + ts = ts, + nonceB64 = "AAAAAAAAAAAAAAAAAAAAAA", + sigHex = "" + ) + val signature = encryptionService.signData(payload.canonicalBytes())!! + return payload.copy(sigHex = signature.hexEncodedString()).toUrlString() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/BridgeRaceRegressionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/BridgeRaceRegressionTest.kt new file mode 100644 index 00000000..338437f4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/BridgeRaceRegressionTest.kt @@ -0,0 +1,57 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.BridgeOutboundPolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BridgeRaceRegressionTest { + @Test + fun `later opt in cannot authorize a send that was previously denied`() { + val policyAtSend = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false) + val policyAtPublish = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false) + + assertFalse(policyAtSend.permitsPublication(policyAtPublish)) + } + + @Test + fun `later opt out still blocks a send that was previously allowed`() { + val policyAtSend = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false) + val policyAtPublish = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false) + + assertFalse(policyAtSend.permitsPublication(policyAtPublish)) + } + + @Test + fun `publication requires bridge permission at send and publish time`() { + val allowed = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false) + val nearbyOnly = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = true) + + assertTrue(allowed.permitsPublication(allowed)) + assertFalse(nearbyOnly.permitsPublication(allowed)) + assertFalse(allowed.permitsPublication(nearbyOnly)) + } + + @Test + fun `delayed close targets the retired subscription generation`() { + val slot = RelaySubscriptionSlot("bridge") + val activeOnRelay = mutableSetOf() + val delayedCloses = mutableListOf() + + val first = slot.replace( + close = delayedCloses::add, + open = activeOnRelay::add + ) + val second = slot.replace( + close = delayedCloses::add, + open = activeOnRelay::add + ) + + delayedCloses.forEach(activeOnRelay::remove) + + assertNotEquals(first, second) + assertFalse(first in activeOnRelay) + assertTrue(second in activeOnRelay) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/GatewayEventPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/GatewayEventPolicyTest.kt new file mode 100644 index 00000000..c1d903bd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/GatewayEventPolicyTest.kt @@ -0,0 +1,25 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.model.NostrCarrierPacket +import com.bitchat.android.nostr.NostrEvent +import com.bitchat.android.nostr.NostrKind +import org.junit.Assert.* +import org.junit.Test + +class GatewayEventPolicyTest { + private val now = 1_800_000_000L + private fun carrier(age: Long = 0, kind: Int = NostrKind.EPHEMERAL_EVENT, cell: String = "s000") = + NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, "s000", + NostrEvent(pubkey = "00".repeat(32), createdAt = (now - age).toInt(), kind = kind, + tags = listOf(listOf("g", cell)), content = "synthetic gateway event"))!! + + @Test fun `accepts exact timestamp boundaries for signature verification`() { + listOf(-900L, 0L, 900L).forEach { assertNotNull(GatewayEventPolicy.inspect(carrier(it), now)) } + } + @Test fun `rejects stale future wrong kind and mismatched channel before signature work`() { + assertNull(GatewayEventPolicy.inspect(carrier(901), now)) + assertNull(GatewayEventPolicy.inspect(carrier(-901), now)) + assertNull(GatewayEventPolicy.inspect(carrier(kind = NostrKind.TEXT_NOTE), now)) + assertNull(GatewayEventPolicy.inspect(carrier(cell = "s001"), now)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt new file mode 100644 index 00000000..c92bc4ef --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt @@ -0,0 +1,129 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.noise.CourierNoiseCrypto +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +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.assertTrue +import org.junit.Test + +class PrekeyManagerTest { + @Test + fun `message retry reuses assigned prekey and recipient grace key`() { + val now = 1_750_000_000_000L + val recipientIdentity = identity(seed = 1) + val senderIdentity = identity(seed = 65) + var generatedSeed = 100 + val recipient = manager(recipientIdentity) { + ByteArray(32) { index -> (generatedSeed + index).toByte() } + .also { generatedSeed += 1 } + } + val sender = manager(senderIdentity) + val bundle = requireNotNull(recipient.currentSignedBundle(now)) + + assertTrue( + sender.verifyAndIngest( + bundle, + expectedNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + announceBoundSigningKey = requireNotNull(recipientIdentity.signingKey()).second, + nowMs = now + ) + ) + + val payload = "offline hello".toByteArray() + val first = sender.seal( + payload, + messageId = "message-1", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + ) + val retry = sender.seal( + payload, + messageId = "message-1", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + 1 + ) + + assertNotNull(first.prekeyId) + assertEquals(first.prekeyId, retry.prekeyId) + val firstOpened = recipient.open(first.ciphertext, first.prekeyId, now + 2) + val retryOpened = recipient.open(retry.ciphertext, retry.prekeyId, now + 3) + assertArrayEquals(payload, firstOpened.payload) + assertArrayEquals(payload, retryOpened.payload) + assertTrue(firstOpened.consumedPrekey) + assertFalse(retryOpened.consumedPrekey) + + val nextMessage = sender.seal( + payload, + messageId = "message-2", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + 4 + ) + assertNotEquals(first.prekeyId, nextMessage.prekeyId) + } + + private fun manager( + identity: PrekeyIdentity, + randomBytes: () -> ByteArray = { ByteArray(32) { (it + 11).toByte() } } + ): PrekeyManager = + PrekeyManager( + identity = identity, + localStore = MemoryLocalPrekeyStore(), + peerStore = MemoryPeerPrekeyStore(), + randomBytes = randomBytes + ) + + private fun identity(seed: Int): PrekeyIdentity { + val staticPrivate = ByteArray(32) { (seed + it).toByte() } + val signingPrivate = Ed25519PrivateKeyParameters( + ByteArray(32) { (seed + 32 + it).toByte() }, + 0 + ) + return FakePrekeyIdentity( + static = staticPrivate to CourierNoiseCrypto.publicKey(staticPrivate), + signing = signingPrivate.encoded to signingPrivate.generatePublicKey().encoded + ) + } + + private class FakePrekeyIdentity( + private val static: Pair, + private val signing: Pair + ) : PrekeyIdentity { + override fun staticKey(): Pair = static + override fun signingKey(): Pair = signing + } + + private class MemoryLocalPrekeyStore : LocalPrekeyStore { + private var state = LocalPrekeyState() + + override fun load(): LocalPrekeyState = state + + override fun save(state: LocalPrekeyState) { + this.state = state + } + + override fun clear() { + state = LocalPrekeyState() + } + } + + private class MemoryPeerPrekeyStore : PeerPrekeyStore { + private var bundles = mutableMapOf() + + override fun load(): MutableMap = bundles + + override fun save(bundles: Map) { + this.bundles = bundles.toMutableMap() + } + + override fun clear() { + bundles.clear() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphRouteTest.kt b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphRouteTest.kt new file mode 100644 index 00000000..451f7081 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphRouteTest.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.services.meshgraph + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class MeshGraphRouteTest { + private lateinit var graph: MeshGraphService + + @Before + fun setUp() { + MeshGraphService.resetForTesting() + graph = MeshGraphService.getInstance() + } + + @After + fun tearDown() { + MeshGraphService.resetForTesting() + } + + @Test + fun `route uses shortest confirmed path`() { + graph.updateFromAnnouncement("a", "alice", listOf("b"), 1u) + graph.updateFromAnnouncement("b", "bob", listOf("a", "c"), 2u) + graph.updateFromAnnouncement("c", "carol", listOf("b"), 3u) + + assertEquals(listOf("a", "b", "c"), graph.computeRoute("a", "c")) + } + + @Test + fun `route ignores one-sided claims`() { + graph.updateFromAnnouncement("a", "alice", listOf("b"), 1u) + graph.updateFromAnnouncement("b", "bob", emptyList(), 2u) + + assertNull(graph.computeRoute("a", "b")) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt index 3a9bfb62..c6c30582 100644 --- a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt @@ -18,6 +18,7 @@ class GCSFilterTest { // Build filter with plenty of bytes (no trimming) val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01) + assertEquals(ids.size, params.includedCount) val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) for (id in ids) { @@ -47,7 +48,7 @@ class GCSFilterTest { val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) // Let's verify that the first trimmedN elements in ids are all matched - val trimmedN = (params.m ushr params.p).toInt() + val trimmedN = params.includedCount assertTrue("At least some elements should have been encoded", trimmedN > 0) val retainedIds = ids.take(trimmedN) diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt new file mode 100644 index 00000000..e59e9e5b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt @@ -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 = System.currentTimeMillis().toULong(), + 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() + 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 + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt new file mode 100644 index 00000000..1ffc94a7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt @@ -0,0 +1,223 @@ +package com.bitchat.android.sync + +import android.content.ContextWrapper +import android.os.Build +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class GossipSyncManagerTest { + private val config = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity() = 100 + override fun gcsMaxBytes() = 400 + override fun gcsTargetFpr() = 0.01 + } + + @Test + fun `whole messages retain six hours while fragments retain fifteen minutes`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 5 * 60 * 60 * 1000L, 1)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 7 * 60 * 60 * 1000L, 2)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 10 * 60 * 1000L, 3)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 20 * 60 * 1000L, 4)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now - 10 * 60 * 1000L, 5)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + ) + ) + + assertEquals(listOf(1, 3, 5), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `public history tolerates bounded future clock skew`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 60_000L, 1)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 60_000L, 2)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now + 60_000L, 3)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 11 * 60_000L, 4)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 11 * 60_000L, 5)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + ) + ) + + assertEquals(listOf(1, 2, 3), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `type scoped fragment request does not replay other packet classes`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = recordingDelegate(sent) + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now, 3)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.FRAGMENT + ) + ) + + assertEquals(listOf(2), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `coverage cursor prevents replay of an identical history tail`() { + val tinyFilterConfig = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity() = 100 + override fun gcsMaxBytes() = 1 + override fun gcsTargetFpr() = 0.01 + } + val requester = GossipSyncManager("1111222233334444", TestScope(), tinyFilterConfig) + val responder = GossipSyncManager("5555666677778888", TestScope(), tinyFilterConfig) + val now = System.currentTimeMillis() + val history = listOf( + packet(MessageType.MESSAGE, now - 100, 1), + packet(MessageType.MESSAGE, now - 200, 2), + packet(MessageType.MESSAGE, now - 300, 3) + ) + history.forEach { + requester.onPublicPacketSeen(it) + responder.onPublicPacketSeen(it) + } + val request = RequestSyncPacket.decode( + requester.buildGcsPayload(SyncTypeFlags.PUBLIC_MESSAGES) + )!! + val sent = mutableListOf() + responder.delegate = recordingDelegate(sent) + + responder.handleRequestSync("peer", request) + + assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, request.types) + assertEquals(history.first().timestamp, request.sinceTimestamp) + assertEquals(emptyList(), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `legacy request without type metadata remains public message only`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = recordingDelegate(sent) + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2)) + + manager.handleRequestSync("peer", RequestSyncPacket(p = 1, m = 1, data = byteArrayOf())) + + assertEquals(listOf(1), sent.map { it.payload.single().toInt() }) + } + + @Test + fun `public history survives manager recreation for post-reconnect sync`() { + val filesDir = Files.createTempDirectory("gossip-sync-test-").toFile() + val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getApplicationContext() = this + override fun getFilesDir(): File = filesDir + } + try { + val now = System.currentTimeMillis() + GossipSyncManager("1111222233334444", TestScope(), config, context) + .onPublicPacketSeen(packet(MessageType.MESSAGE, now, 42)) + + val sent = mutableListOf() + val restored = GossipSyncManager("1111222233334444", TestScope(), config, context) + restored.delegate = recordingDelegate(sent) + restored.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.PUBLIC_MESSAGES + ) + ) + + assertEquals(listOf(42), sent.map { it.payload.single().toInt() }) + } finally { + filesDir.deleteRecursively() + } + } + + @Test + fun `announcement-only request returns the latest announcement independently of message history`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = recordingDelegate(sent) + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.ANNOUNCE, now - 1_000, 6)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 7)) + + manager.handleRequestSync( + "peer", + RequestSyncPacket( + p = 1, + m = 1, + data = byteArrayOf(), + types = SyncTypeFlags.ANNOUNCE + ) + ) + + assertEquals(listOf(6), sent.map { it.payload.single().toInt() }) + } + + private fun recordingDelegate(sent: MutableList) = + object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent += packet + } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + + private fun packet(type: MessageType, timestamp: Long, marker: Int) = BitchatPacket( + type = type.value, + senderID = ByteArray(8) { 1 }, + timestamp = timestamp.toULong(), + payload = byteArrayOf(marker.toByte()), + ttl = 3u + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt new file mode 100644 index 00000000..fe42e1d0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt new file mode 100644 index 00000000..f4aa084c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt @@ -0,0 +1,36 @@ +package com.bitchat.android.sync + +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncTypeFlagsTest { + @Test + fun `wire encoding uses compact little endian iOS bit positions`() { + val flags = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + + assertArrayEquals(byteArrayOf(0xa3.toByte()), flags.encode()) + assertEquals(flags, SyncTypeFlags.decode(byteArrayOf(0xa3.toByte()))) + assertTrue(flags.contains(MessageType.ANNOUNCE)) + assertTrue(flags.contains(MessageType.FILE_TRANSFER)) + assertFalse(flags.contains(MessageType.NOISE_HANDSHAKE)) + } + + @Test + fun `unknown extended bits are normalized away`() { + val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xf8.toByte()))!! + + assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, decoded) + assertArrayEquals(byteArrayOf(0x03), decoded.encode()) + } + + @Test + fun `empty and oversized fields are rejected`() { + assertNull(SyncTypeFlags.decode(byteArrayOf())) + assertNull(SyncTypeFlags.decode(ByteArray(9))) + } +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 05d5e1ad..4927c30e 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -15,8 +15,9 @@ The remaining implementation work and milestone progress are tracked in | Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` | | Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` | | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | +| Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget and prekey-ID TLVs, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` | | Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` | -| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | +| Sync/routing | Stable packet IDs, GCS bitstream, type-scoped filters, bounded-history cursors, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewriteWireContractTest`, `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `GossipSyncManagerTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | | Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | | Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` | @@ -58,8 +59,8 @@ shipping a rewrite, run the following on at least two physical devices: 2. Runtime permission denial/retry for Bluetooth, location, notifications, and microphone. 3. Foreground-service survival with the screen off and after process recreation. -4. Cross-client Android/iOS exchange for announce, public/private text, delivery - and read receipts, image/audio/file transfer, sync replay, and Nostr fallback. +4. Cross-client Android/iOS exchange for announce, public/private text, courier + deposit/handover, delivery/read receipts, media, six-hour sync replay, and Nostr fallback. 5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments. 6. Identity rotation, verification continuity, downgrade rejection, and recovery after stale Noise sessions. diff --git a/docs/ios-feature-parity-review.md b/docs/ios-feature-parity-review.md new file mode 100644 index 00000000..a27d28d5 --- /dev/null +++ b/docs/ios-feature-parity-review.md @@ -0,0 +1,124 @@ +# iOS feature parity review + +Reviewed against iOS commit `9b84b361225facd8e623f25d76f889d3dc54a879` and Android main +`936a4cdf6d91a944698f7b46528962aef09c1f9c`. The merged-PR window is August 9 through +September 8, 2026 (UTC). This is a source and automated-test assessment; it is not +an assertion of physical Android/iOS interoperability. + +## Recent iOS changes that matter + +| Merged PR | Android finding and action | +| --- | --- | +| [#1647 — timestamp sanity](https://github.com/permissionlesstech/bitchat/pull/1647) | Bound verification QR timestamps in both directions without integer overflow; reject implausible Nostr DM rumor timestamps separately from randomized outer envelopes. Validate kind, signature, recipient and rumor/seal consistency on the common decrypt path. | +| [#1598 — reachable recent chats](https://github.com/permissionlesstech/bitchat/pull/1598) | Android main already persists and exposes recent private conversations. Preserve that implementation; integrate new outboxes and groups with it. | +| [#1597 — connectivity truthfulness](https://github.com/permissionlesstech/bitchat/pull/1597) | Add a persistent Bluetooth/Tor availability banner. Failed handshakes display an open lock; a closed lock requires an established session. | +| [#1595 — honest privacy claims](https://github.com/permissionlesstech/bitchat/pull/1595) | Default notification text previews off, make enabling them explicit, and describe panic wipe as local deletion with visible outcomes. | +| [#1588 — confirmable panic wipe](https://github.com/permissionlesstech/bitchat/pull/1588) | Replace immediate triple-tap erasure with confirmation, progress, success, and retryable failure. Suspend private writes and background forwarding during erasure; surface failures from persistent stores. | +| [#1596 — remove screenshot broadcast](https://github.com/permissionlesstech/bitchat/pull/1596) | No new screenshot reporting is added. This iOS removal is a privacy correction, not a feature to port. | +| [#1657](https://github.com/permissionlesstech/bitchat/pull/1657), [#1656](https://github.com/permissionlesstech/bitchat/pull/1656), [#1654](https://github.com/permissionlesstech/bitchat/pull/1654) — localization | Android has its own resource/localization system. New controls use resources; full translation coverage for the newly integrated group/board/settings surface remains follow-up work. | +| [#1653](https://github.com/permissionlesstech/bitchat/pull/1653), [#1651](https://github.com/permissionlesstech/bitchat/pull/1651) — deterministic tests | Keep coroutine/queue tests deterministic and exercise observable admission and revocation behavior. Swift-specific timing changes do not require an Android port. | +| [#1648 — blocking dead-code scan](https://github.com/permissionlesstech/bitchat/pull/1648) | Preserve Android lint and client rewrite contract gates; Periphery itself is Swift-specific. | + +The immediately preceding [#1645](https://github.com/permissionlesstech/bitchat/pull/1645) +(fail-closed handshake identity and secure randomness) and +[#1646](https://github.com/permissionlesstech/bitchat/pull/1646) (blocking SwiftLint) +were also inspected as context, but merged August 8 and are outside this window. +Android already has authenticated Noise peer binding and secure-random primitives; +new courier/group paths must preserve those properties. + +## Feature matrix and implementation + +Paths in the iOS column are relative to the iOS repository. Android paths are +relative to this repository. Features below also include older iOS functionality +still absent from Android main; they are not all attributed to the recent PRs. + +| Capability | iOS evidence | Android main | This implementation | +| --- | --- | --- | --- | +| Durable private text retries, offline courier | `bitchat/Services/Courier/MessageOutboxStore.swift`, `bitchat/Services/Gateway/BridgeCourierService.swift` | No equivalent durable outbound courier pipeline | Keystore-encrypted text outbox, retry/expiry and authenticated acknowledgements; direct mesh deposits and opt-in relay courier. | +| Durable private media receipts | `bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift`, `BLEPrivateMediaSessionStore.swift` | Encrypted private media and PTT already exist; no stable receipt/retry lifecycle | iOS-compatible stable IDs, encrypted bounded media outbox, durable receiver admission, duplicate/tombstone ACKs, matching-recipient retries, and single-row finalized PTT notes. | +| One-time prekeys | `bitchat/Services/Prekeys/LocalPrekeyStore.swift`, `PrekeyBundleStore.swift` | Not available | Signed bundle validation, persist-before-use assignment/consumption, refresh and typed synchronization. | +| Private groups | `bitchat/Services/Groups/GroupProtocol.swift`, `GroupStore.swift`, `bitchat/ViewModels/ChatGroupCoordinator.swift` | Not available | Creator-signed membership/key epochs, encrypted messages, 16-member bound, replay checks, group commands/list, durable history and process-lifetime handling. | +| Signed notices and mesh board synchronization | `bitchat/Protocols/BoardPackets.swift`, `bitchat/Services/Board/BoardManager.swift` | Location notes already exist, signed mesh board absent | Signed creation/deletion, TTL, scoped synchronization, storage and unified notices UI. Preserve existing location privacy gates. | +| Vouch attestations | `bitchat/Protocols/VouchAttestation.swift`, `bitchat/ViewModels/ChatVouchCoordinator.swift` | Direct verification only | Signed bounded-age attestations and persisted derived trust; a vouch remains distinct from direct verification. | +| Mesh-to-mesh relay bridge | `bitchat/Services/Gateway/BridgeService.swift` | Not available | Explicit opt-in forwarding with event validation, bounded dedup/rate limits, and revocable queued publication permissions. | +| Nearby internet gateway | `bitchat/Services/Gateway/GatewayService.swift` | Not available | Separate default-off gateway toggle; signed geohash events use carrier directions 1/2, authenticated capability discovery, timestamp checks, bounded forwarding and relay-echo suppression. | +| Custom relays | `bitchat/Nostr/NostrRelaySettings.swift`, `NostrRelayURL.swift` | Built-in relay selection | Persistent custom secure WebSocket relays, URL validation, subscription updates, removal and panic cleanup. | +| Incoming text/URL sharing | `bitchat/Services/SharedContentHandoff.swift`, `bitchat/App/SharedContentImportModel.swift` | No incoming text share target | Android text share target stages a bounded draft for user review; never auto-sends. | +| Channel invitations | `bitchat/Services/ChannelShare.swift` | No invitation import/export | Explicit `bitchat://geohash/…` links and share action; import selects a manual channel without requesting device location. | +| Notification privacy | `bitchat/Services/NotificationPrivacySettings.swift` | Sender/text previews exposed by default | Generic notification content by default and explicit preview control; turning previews off clears existing notification/shortcut surfaces. | +| Ping/route diagnostics | iOS mesh diagnostic packet/command paths | No matching commands | Versioned ping/pong payloads, bounded TTL/rate handling, `/ping`, `/trace` and mesh topology display. | + +Android main already supports Noise encryption, Nostr/Tor location chat, QR +verification, private image/audio transfer, live PTT, Cashu handling, location +notes and retained private conversations. These are integration constraints, +not missing features. Peer-ID rotation is not included: an iOS primitive alone +does not establish an end-to-end feature to advertise. + +## Implementation strategy + +1. Reuse and integrate the existing Android work for courier delivery, groups, + signed boards, vouching, bridging and diagnostics instead of introducing a + competing router or conversation database. The integration builds on PRs + [#877](https://github.com/permissionlesstech/bitchat-android/pull/877), + [#769](https://github.com/permissionlesstech/bitchat-android/pull/769), + [#768](https://github.com/permissionlesstech/bitchat-android/pull/768), + [#778](https://github.com/permissionlesstech/bitchat-android/pull/778) and + [#770](https://github.com/permissionlesstech/bitchat-android/pull/770), with + verification hardening informed by #910 and #927. +2. Treat durable receipt semantics, authenticated capabilities, timestamp checks, + panic admission gates and publication revocation as prerequisites for safe + feature exposure. A relay accepting an event is not a recipient delivery ACK. +3. Own group and retry work at application/process lifetime. Activity attachment + supplies navigation only; it must not decide whether an encrypted message is + persisted or acknowledged. +4. Keep phone-only capability bits disabled on Wear until their runtimes are + present. Shared packet parsing/sync comes from `app/` through the Wear source + include list. Private-media encryption remains independent of receipt support. +5. Validate wire vectors and storage/race behavior locally, then run physical + Mesh Lab and iOS interop before treating parity as release-ready. + +## Wire and persistence contract + +- Existing wire values are retained. Group invite/update use Noise types 6/7; + group messages use packet `0x25`; diagnostics use `0x26`/`0x27`. +- Capability flags are advertised only when the owning runtime is present: + prekeys bit 0, gateway bit 2, groups bit 3, boards bit 4, vouch bit 5, + diagnostics bit 6, bridge bit 7, private media bit 8 and media receipts bit 9. + Gateway and bridge bits follow their independent opt-in settings. +- Typed gossip includes signed boards, prekeys and groups, retaining compatibility + with legacy sync masks. See [sync.md](sync.md). +- Stable media message IDs hash the iOS domain and length-prefixed sender, + recipient and supported filename. `PrivateMediaMessageIdentityTest` contains + an exact iOS-compatible vector. Original encoded payloads survive retries. +- Private-media outbox limits: 100 records, 8 MiB per record, 64 MiB total, + eight attempts and 24-hour lifetime. Only authenticated live receipt support + enables retry. Missing/corrupt storage does not produce a delivery ACK. +- A deleted received message remains acknowledged through its durable tombstone; + replay must not recreate it. Encrypted media also requires a readable saved file. +- Bridge/gateway publication permissions carry a generation. Turning a feature + off invalidates both disconnected-queue entries and already-selected writes; + re-enabling cannot revive old permission. +- Channel invitation tests use synthetic cells only. No implementation/test + workflow requires reading real device location. + +## Release-readiness and remaining work + +Local validation results and visual evidence are reported in the pull request. +Physical Mesh Lab and Android-to-iOS interoperability are required separately: + +- Offline text/media delivery, process death and restart, duplicate receipt after + deletion, blocked/expired/corrupt payloads and panic during retry. +- BLE and Wi-Fi Aware delivery with the Activity closed; multi-hop courier with + the original sender unavailable; phone-to-watch capability fallback. +- Group invite/update/removal, stale epochs, restart and background notification. +- Board signature/delete/sync and bridge/gateway disable/re-enable while relays + disconnect or delayed writes are queued. +- One-time-prekey races and consumption persistence, plus iOS vectors on both + real clients. Static screenshots cannot establish any of these properties. + +The updated panic-help sentence uses the English resource fallback until translated. +Full locale coverage, accessibility review and the physical matrix remain +release work. Manual-channel Nostr board publication still obeys the existing +location-note privacy gate; this integration does not weaken it to force a +successful publish. No claim is made that this large integration is ready to +merge without the hardware and cross-client review above. diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 8de1e063..24e5ddca 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -298,6 +298,12 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `dm` | Noise handshake both ways, encrypted DM round trips with content match | | `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification | | `broadcast` | public mesh message A→B | +| `sync_recovery` | message missed while B's BLE and Wi-Fi Aware transports are disabled is recovered by the gossip sync request after reconnection | +| `sync_auto_recovery` | message missed while B is offline is recovered after restart by the production gossip scheduler, without a test-hook sync request | +| `sync_file_recovery` | broadcast file missed while B is offline is recovered through sync; validates the synthetic file digest and fragment replay | +| `durable_outbox` | a router-queued DM survives sender process death while the recipient is offline, then reaches the recipient after reconnection | +| `courier_delivery` | three-phone sender→courier→recipient handoff: recipient is offline for deposit and sender remains offline for recipient pickup | +| `courier_contract` | on-device courier wire/store contract: prekey TLV retention, split reservations, cancellation, reverse commits, and persisted spray history | | `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption | | `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions | | `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | @@ -307,7 +313,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `raw` | raw packet injection is accepted by the mesh | | `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again | | `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs | -| `all` | every scenario above in sequence | +| `all` | every supported two-phone scenario in sequence, plus `courier_delivery` when a third phone is supplied | Each run writes `-evidence.json` to `--out` (digests, timings, session states, logcat excerpts on failure) and exits non-zero on failure. @@ -326,10 +332,15 @@ python3 tools/release_gate/mesh_lab.py cmd --serial state # full mesh See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`, -`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`, +`announce`, `broadcast_msg`, `dm_send`, `router_private_send`, `router_resume`, +`dm_recv`, `msg_recv`, `favorite_set`, `favorite_status`, `verification_set`, `verification_status`, `file_send`, -`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`, -`clear_results`). +`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`, +`cache_peer_identity`, `sync_request`, `ble`, `wifi_aware`, `state`, `clear_results`). + +`msg_recv` accepts `include_existing=true` for deterministic backfill checks when +the packet's original timestamp predates the receive hook; use a unique synthetic +content token with this mode. ### Troubleshooting diff --git a/docs/sync.md b/docs/sync.md index 0bbd0735..919a8e69 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -1,17 +1,19 @@ # GCS Filter Sync (REQUEST_SYNC) -This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets (ANNOUNCE and broadcast MESSAGE) across nodes via periodic sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets. +This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets across nodes via periodic, type-scoped sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets. ## Overview - Each node maintains a rolling set of public BitChat packets it has seen recently: - Broadcast messages (MessageType.MESSAGE where recipient is broadcast) - Identity announcements (MessageType.ANNOUNCE) - - Default retention is 100 recent packets (configurable in the debug sheet). This value is the maximum number of packets that are synchronized per request (across both types combined). + - Broadcast fragments (MessageType.FRAGMENT) + - Broadcast file transfers (MessageType.FILE_TRANSFER) + - Default retention is 100 recent packets per local store (configurable in the debug sheet). - Nodes do not maintain a rolling Bloom filter. Instead, they compute a GCS filter on demand when sending a REQUEST_SYNC. -- Every 30 seconds, a node sends a REQUEST_SYNC packet to all immediate neighbors (local only; not relayed). -- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a REQUEST_SYNC only to that peer (unicast; local only). -- The receiver checks which packets are not in the sender’s filter and sends those packets back. For announcements, only the latest announcement per peerID is sent; for broadcast messages, all missing ones are sent. +- Every 30 seconds, Android sends separate public-message, fragment, and file-transfer REQUEST_SYNC rounds to immediate neighbors (local only; not relayed). Keeping the filters type-scoped prevents one high-volume class from crowding another out. +- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a combined type-scoped REQUEST_SYNC only to that peer (unicast; local only). +- The receiver checks only the packet types named by the request and sends packets absent from the filter. For announcements, only the latest announcement per peerID is sent. This synchronization is strictly local (not relayed), ensuring only immediate neighbors participate and preventing wide-area flooding while converging content across the mesh. @@ -36,9 +38,7 @@ Implementation: `com.bitchat.android.sync.GCSFilter`. - Maximum number of elements that fit into the filter is estimated as: N_max ≈ floor((8 * sizeBytes) / (P + 2)) - This estimate is used to cap the set; the actual encoder will trim further if needed to stay within the configured size. - What goes into the set: - - Combine the following and sort by packet timestamp (descending): - - Broadcast messages (MessageType 1) - - The most recent ANNOUNCE per peer + - Select only the classes named by the request's `types` field, then sort matching packets by timestamp (descending). Android stores broadcast messages, the most recent ANNOUNCE per peer, broadcast fragments, and broadcast file transfers. - Take at most `min(N_max, maxPacketsPerSync)` items from this ordered list. - Compute the 16-byte Packet ID (see below), then for hashing use the first 8 bytes of SHA‑256 over the 16‑byte ID. - Map each hash to [0, M) with M = N * 2^P; sort ascending and encode deltas with Golomb‑Rice parameter P. @@ -56,6 +56,10 @@ MessageType: `REQUEST_SYNC (0x21)` - 0x01: P (uint8) — Golomb‑Rice parameter - 0x02: M (uint32) — hash range N * 2^P - 0x03: data (opaque) — GCS bitstream (MSB‑first bit packing) + - 0x04: types (1–8 byte compact little-endian bitfield) — packet classes represented by this filter + - 0x05: sinceTimestamp (uint64, big-endian) — timestamp of the oldest packet represented when the candidate tail did not fit + +`types` uses the cross-client bit mapping: ANNOUNCE=bit 0, MESSAGE=bit 1, LEAVE=bit 2, NOISE_HANDSHAKE=bit 3, NOISE_ENCRYPTED=bit 4, FRAGMENT=bit 5, REQUEST_SYNC=bit 6, and FILE_TRANSFER=bit 7. Implementations ignore bits they do not recognize. When `types` is absent, receivers use the legacy scope of ANNOUNCE plus broadcast MESSAGE. Notes: - The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte). @@ -71,7 +75,9 @@ Sender behavior: Receiver behavior: - Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream. -- For each locally stored public packet ID: +- Restrict the diff to the packet classes in `types` (or the legacy public-message scope when absent). +- For each locally stored packet ID in that scope: + - If `sinceTimestamp` is present, skip messages, fragments, and files older than it because they were outside the requester's bounded filter rather than missing. ANNOUNCE is exempt so a peer can still obtain current verification keys. - Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only. - For announcements, send only the latest announcement per (sender peerID). - For broadcast messages, send all missing ones. @@ -90,6 +96,8 @@ Important: original packets are sent unmodified to preserve original signatures Included in sync: - Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient). - Identity announcements: `MessageType.ANNOUNCE`. +- Broadcast fragments: `MessageType.FRAGMENT` with BROADCAST recipient (or null recipient). +- Broadcast files: `MessageType.FILE_TRANSFER` with BROADCAST recipient (or null recipient). - Both packets produced by other peers and packets produced by the requester itself MUST be represented in the requester’s GCS; the responder MUST track and consider its own produced public packets as candidates to return when they are missing on the requester. - Announcements included in the GCS MUST be at most 60 seconds old at the time of filter construction; older announcements are excluded by pruning. @@ -130,8 +138,8 @@ The following items require consensus across all implementations to ensure inter - Packet ID recipe: first 16 bytes of SHA‑256(type | senderID | timestamp | payload). - GCS hashing function and mapping to [0, M) as specified above (v1), and MSB‑first bit packing for the bitstream. -- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque). -- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only ANNOUNCE and broadcast MESSAGE are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”. +- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque), 0x04 = compact little-endian type flags, and 0x05 = big-endian coverage cursor. +- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only requested, supported public packet classes are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”. The following are requester‑defined and communicated or local policy (no global agreement required): @@ -142,7 +150,7 @@ The following are requester‑defined and communicated or local policy (no globa Validation and limits (recommended): - Reject malformed REQUEST_SYNC payloads (e.g., P < 1, M <= 0, or data length too large for local limits). -- Practical bounds: data length in [0, 1024]; P in [1, 24]; M up to 2^32‑1. +- Practical bounds: data length in [0, 1024]; P in [1, 32]; M up to 2^32‑1. Versioning: diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 7abe8085..99aefc5b 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -166,11 +166,14 @@ class Device: def reset_bluetooth(self) -> None: """Cycle the BT adapter; clears zombie GATT connections from peer restarts.""" - _shell(self.serial, "svc bluetooth disable") + self.disable_bluetooth() time.sleep(2) _shell(self.serial, "svc bluetooth enable") time.sleep(3) + def disable_bluetooth(self) -> None: + _shell(self.serial, "svc bluetooth disable") + def enable_bluetooth(self) -> None: subprocess.run( [find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"], @@ -365,6 +368,31 @@ def setup_pair( wait_for_mutual_discovery(a, b) +def setup_triad( + a: Device, + b: Device, + c: Device, + apk: Path | None, + nickname_a: str, + nickname_b: str, + nickname_c: str, +) -> None: + """Install and isolate three phones, then ensure every pair can discover each other.""" + for device, nickname in ((a, nickname_a), (b, nickname_b), (c, nickname_c)): + device.reset_bluetooth() + device.enable_bluetooth() + if apk is not None: + device.install(apk) + device.clear_app_data() + device.grant_permissions() + device.wake() + device.launch() + device.cmd_ok("start") + device.cmd_ok("set_nickname", name=nickname) + for left, right in ((a, b), (a, c), (b, c)): + wait_for_mutual_discovery(left, right) + + def whoami(device: Device) -> dict: return device.cmd_ok("whoami") @@ -391,6 +419,47 @@ def wait_for_mutual_discovery(a: Device, b: Device) -> None: fb.result() +def wait_for_peer_absent(device: Device, peer_id: str, timeout_s: int = 90) -> None: + """Wait until the peer is absent or no longer connected in the visible mesh state.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + peers = device.cmd_ok("peers").get("peers", []) + peer = next((item for item in peers if item.get("id") == peer_id), None) + if peer is None or not peer.get("connected", False): + return + time.sleep(2) + raise MeshLabError(f"[{device.alias}] peer {peer_id} remained connected after transport shutdown") + + +def disable_transports(device: Device) -> bool: + """Make a phone unreachable through either local mesh transport and return its Wi-Fi setting.""" + device.cmd_ok("ble", enabled=False) + return bool(device.cmd_ok("wifi_aware", enabled=False)["previous_enabled"]) + + +def resume_transports(device: Device, wifi_enabled: bool) -> None: + """Bring a force-stopped phone back as the same identity with its prior Wi-Fi setting.""" + device.wake() + device.launch() + device.cmd_ok("start") + device.cmd_ok("ble", enabled=True) + device.cmd_ok("wifi_aware", enabled=wifi_enabled) + + +def take_device_offline(device: Device) -> bool: + """Stop the app and radio so the counterpart must observe a real link loss.""" + wifi_enabled = disable_transports(device) + device.force_stop() + device.disable_bluetooth() + return wifi_enabled + + +def bring_device_online(device: Device, wifi_enabled: bool) -> None: + device.enable_bluetooth() + time.sleep(3) + resume_transports(device, wifi_enabled) + + # MARK: - scenarios def scenario_dm(a: Device, b: Device) -> dict: @@ -532,6 +601,306 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} +def scenario_sync_recovery(a: Device, b: Device) -> dict: + """Miss a public message while B's transports are offline, then receive it via gossip sync.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + token = f"sync-{uuid.uuid4().hex[:8]}" + b.cmd_ok("ble", enabled=False) + wifi_before = b.cmd_ok("wifi_aware", enabled=False)["previous_enabled"] + try: + send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"sync recovery {token}") + # The hook returns after dispatching the send coroutine. Give the sender's + # gossip observer time to retain the packet before taking B offline. + time.sleep(2) + offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True) + if offline_result.get("status") == "ok": + raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}") + + # Restart B so the initial sync request is scheduled from a fresh peer + # observation instead of relying on an already-cached relationship. + b.force_stop() + b.wake() + b.launch() + b.cmd_ok("start") + b.cmd_ok("ble", enabled=True) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit( + b.cmd_ok, + "msg_recv", + 180_000, + contains=token, + include_existing=True, + ) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + b.cmd_ok("sync_request", peer=id_a) + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "send": send_result, + "offline_delivery": {"status": offline_result.get("status", "missing")}, + "synced_recv": recv_result, + } + finally: + # Leave the pair usable if an assertion or timeout interrupts the recovery. + try: + b.cmd_ok("ble", enabled=True) + except MeshLabError: + pass + try: + b.cmd_ok("wifi_aware", enabled=wifi_before) + except MeshLabError: + pass + + +def scenario_sync_auto_recovery(a: Device, b: Device) -> dict: + """A restarted receiver recovers a missed public message without a test-hook sync request.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + token = f"sync-auto-{uuid.uuid4().hex[:8]}" + wifi_before = disable_transports(b) + try: + send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"automatic sync recovery {token}") + time.sleep(2) + offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True) + if offline_result.get("status") == "ok": + raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}") + + b.force_stop() + resume_transports(b, wifi_before) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit( + b.cmd_ok, + "msg_recv", + 180_000, + contains=token, + include_existing=True, + ) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + # No sync_request command here: this must arrive from the production initial/periodic + # gossip scheduler created by the restarted receiver. + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "send": send_result, + "offline_delivery": {"status": offline_result.get("status", "missing")}, + "synced_recv": recv_result, + } + finally: + try: + bring_device_online(b, wifi_before) + except MeshLabError: + pass + + +def scenario_sync_file_recovery(a: Device, b: Device) -> dict: + """Sync an offline-missed broadcast transfer, exercising FILE_TRANSFER and FRAGMENT replay.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + fixture = make_fixtures( + Path(tempfile.mkdtemp(prefix="meshlab-sync-file-")), + names=["small_1k.bin"], + )["small_1k.bin"] + name = f"sync-file-{uuid.uuid4().hex[:8]}.bin" + remote = a.push_fixture(fixture["path"], name=name) + b.clear_incoming() + wifi_before = disable_transports(b) + try: + send_result = a.cmd_ok("file_send", 180_000, path=remote) + time.sleep(2) + offline_result = b.cmd("file_recv", 10_000, name_contains=name) + if offline_result.get("status") == "ok": + raise MeshLabError(f"file was delivered while receiver transports were disabled: {offline_result}") + + b.force_stop() + resume_transports(b, wifi_before) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + b.cmd_ok("sync_request", peer=id_a) + recv_result = recv.result() + if recv_result["sha256"] != fixture["sha256"]: + raise MeshLabError("synced file digest did not match the synthetic fixture") + return { + "send": send_result, + "offline_delivery": {"status": offline_result.get("status", "missing")}, + "synced_recv": { + "name": recv_result["name"], + "bytes": recv_result["bytes"], + "digest_match": True, + }, + } + finally: + try: + b.cmd_ok("ble", enabled=True) + except MeshLabError: + pass + try: + b.cmd_ok("wifi_aware", enabled=wifi_before) + except MeshLabError: + pass + + +def scenario_durable_outbox(a: Device, b: Device) -> dict: + """Queue a private message offline, kill the sender, and deliver it after both phones return.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + ensure_direct_link(a, b, id_a, id_b) + force_handshake(a, id_b) + force_handshake(b, id_a) + a.cmd_ok("cache_peer_identity", peer=id_b) + + token = f"durable-outbox-{uuid.uuid4().hex[:8]}" + message_id = f"outbox-{uuid.uuid4().hex}" + wifi_before = take_device_offline(b) + try: + wait_for_peer_absent(a, id_b) + queued: dict | None = None + for _attempt in range(5): + candidate = a.cmd_ok( + "router_private_send", + peer=id_b, + content=f"durable outbox {token}", + msg_id=message_id, + ) + if candidate.get("route") == "QUEUED": + queued = candidate + break + time.sleep(3) + if queued is None: + raise MeshLabError("private message did not enter the durable outbox while recipient was offline") + + # The queued entry must survive a complete sender process death, not just an in-memory + # reconnect. B is restored before A so its receiver is ready for the resumed delivery. + a.force_stop() + bring_device_online(b, wifi_before) + a.wake() + a.launch() + a.cmd_ok("start") + a.cmd_ok("router_resume") + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "dm_recv", 180_000, peer=id_a, contains=token) + time.sleep(2) + ensure_direct_link(a, b, id_a, id_b) + force_handshake(a, id_b) + force_handshake(b, id_a) + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "queued_route": queued["route"], + "sender_process_restarted": True, + "received_after_restart": {"msg_id": recv_result["msg_id"], "from_sender": True}, + } + finally: + try: + bring_device_online(b, wifi_before) + except MeshLabError: + pass + + +def _make_mutual_favorites(a: Device, b: Device, id_a: str, id_b: str) -> None: + """Establish a trusted direct A↔B relationship for a courier deposit.""" + ensure_direct_link(a, b, id_a, id_b) + force_handshake(a, id_b) + force_handshake(b, id_a) + a.cmd_ok("favorite_set", peer=id_b, enabled=True) + b.cmd_ok("favorite_set", peer=id_a, enabled=True) + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + a_status = a.cmd_ok("favorite_status", peer=id_b) + b_status = b.cmd_ok("favorite_status", peer=id_a) + if a_status.get("is_mutual") and b_status.get("is_mutual"): + return + time.sleep(1) + raise MeshLabError("courier depositor and courier did not establish a mutual favorite relationship") + + +def scenario_courier_delivery(a: Device, b: Device, c: Device) -> dict: + """A deposits to B while C is offline; C returns only after A is unavailable.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + id_c = whoami(c)["peer_id"] + _make_mutual_favorites(a, b, id_a, id_b) + ensure_direct_link(a, c, id_a, id_c) + a.cmd_ok("cache_peer_identity", peer=id_c) + + token = f"courier-delivery-{uuid.uuid4().hex[:8]}" + message_id = f"courier-{uuid.uuid4().hex}" + wifi_c = take_device_offline(c) + wifi_a: bool | None = None + try: + wait_for_peer_absent(a, id_c) + ensure_direct_link(a, b, id_a, id_b) + + queued: dict | None = None + for _attempt in range(5): + candidate = a.cmd_ok( + "router_private_send", + peer=id_c, + content=f"courier delivery {token}", + msg_id=message_id, + ) + if candidate.get("route") == "QUEUED": + queued = candidate + break + time.sleep(3) + if queued is None: + raise MeshLabError("offline recipient message did not enter the courier-capable outbox") + + # A must be unavailable when C returns. This prevents the sender's direct outbox retry + # from masking whether B actually retained and handed over the courier envelope. + wifi_a = take_device_offline(a) + wait_for_peer_absent(b, id_a) + + bring_device_online(c, wifi_c) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(c.cmd_ok, "dm_recv", 240_000, peer=id_a, contains=token) + time.sleep(2) + ensure_direct_link(b, c, id_b, id_c) + c.cmd_ok("announce") + recv_result = recv.result() + assert recv_result["from"] == id_a, recv_result + return { + "queued_route": queued["route"], + "sender_offline_before_recipient_return": True, + "received_from_original_sender": True, + "received_message_id": recv_result["msg_id"], + } + finally: + try: + bring_device_online(c, wifi_c) + except MeshLabError: + pass + if wifi_a is not None: + try: + bring_device_online(a, wifi_a) + except MeshLabError: + pass + + +def scenario_courier_contract(a: Device, b: Device) -> dict: + """Run the real courier wire/store contract on device A's debug build.""" + result = a.cmd_ok("courier_contract", 60_000) + expected = { + "wire_prekey_id_preserved": True, + "stored_prekey_id_preserved": True, + "first_reserved_copies": 2, + "second_reserved_copies": 1, + "same_courier_second_reservation_empty": True, + "reverse_order_commits": True, + "cancel_restored_eligibility": True, + "persisted_spray_history": True, + "remaining_copies_after_restart": 1, + } + for field, value in expected.items(): + if result.get(field) != value: + raise MeshLabError(f"courier contract field {field}={result.get(field)!r}, expected {value!r}") + return result + + def _ptt_one_way( sender: Device, receiver: Device, @@ -728,26 +1097,26 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None: wait_for_peer(a, id_b, timeout_s=120) wait_for_peer(b, id_a, timeout_s=120) for device, peer, announcer in ((a, id_b, b), (b, id_a, a)): - connected = False last: dict = {} for _attempt in range(4): last = device.cmd("connect", timeout_ms=45_000, peer=peer) if last.get("status") == "ok" and last.get("direct"): - connected = True - break - # Already acceptable if the mesh formed a direct link on its own. - peers = device.cmd_ok("peers").get("peers", []) - match = next((p for p in peers if p.get("id") == peer), None) - if match and match.get("direct"): - connected = True - break + # A GATT link carries traffic both ways. Requiring a second client connection from + # the other endpoint turns a healthy single direct link into a false test failure, + # especially after one phone has just restarted and has not rebuilt its address map. + return + # Already acceptable if either endpoint formed a direct link on its own. + for observer, observed_peer in ((device, peer), (announcer, whoami(device)["peer_id"])): + peers = observer.cmd_ok("peers").get("peers", []) + match = next((p for p in peers if p.get("id") == observed_peer), None) + if match and match.get("direct"): + return try: announcer.cmd_ok("announce") except MeshLabError: pass time.sleep(4) - if not connected: - raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}") + raise MeshLabError(f"no direct link formed between {a.alias} and {b.alias}: connect={last}") def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict: @@ -869,6 +1238,11 @@ SCENARIOS = { "dm": scenario_dm, "favorite_verification": scenario_favorite_verification, "broadcast": scenario_broadcast, + "sync_recovery": scenario_sync_recovery, + "sync_auto_recovery": scenario_sync_auto_recovery, + "sync_file_recovery": scenario_sync_file_recovery, + "durable_outbox": scenario_durable_outbox, + "courier_contract": scenario_courier_contract, "ptt_dm": scenario_ptt_dm, "ptt_broadcast": scenario_ptt_broadcast, # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only @@ -893,6 +1267,11 @@ SCENARIOS = { "identity_reset": scenario_identity_reset, } +# End-to-end peer courier delivery needs distinct sender, courier, and recipient identities. +TRIAD_SCENARIOS = { + "courier_delivery": scenario_courier_delivery, +} + # Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends, # the watch must receive with matching digests). WATCH_SCENARIOS = [ @@ -909,9 +1288,16 @@ WATCH_SCENARIOS = [ ] -def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: +def run_scenario( + name: str, + a: Device, + b: Device, + out: Path | None, + c: Device | None = None, +) -> dict: started = time.time() - evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]} + devices = [a, b] + ([c] if c is not None else []) + evidence: dict[str, object] = {"scenario": name, "devices": [device.alias for device in devices]} try: supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS) if name == "all": @@ -922,9 +1308,20 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: results[n] = sub.get("results", {"error": sub.get("error", "unknown")}) if sub["status"] != "pass": failures.append(n) + if c is not None: + sub = run_scenario("courier_delivery", a, b, out, c) + results["courier_delivery"] = sub.get("results", {"error": sub.get("error", "unknown")}) + if sub["status"] != "pass": + failures.append("courier_delivery") evidence["results"] = results if failures: raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}") + elif name in TRIAD_SCENARIOS: + if c is None: + raise MeshLabError(f"scenario '{name}' requires --serial-c") + if isinstance(b, WatchDevice): + raise MeshLabError(f"scenario '{name}' requires three phones") + evidence["results"] = TRIAD_SCENARIOS[name](a, b, c) elif name not in supported: raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'") else: @@ -933,7 +1330,7 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: except (MeshLabError, AssertionError) as error: evidence["status"] = "fail" evidence["error"] = str(error) - evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)} + evidence["logcat"] = {d.alias: d.logcat_dump() for d in devices} evidence["duration_s"] = round(time.time() - started, 1) if out is not None: out.mkdir(parents=True, exist_ok=True) @@ -950,16 +1347,19 @@ def build_parser() -> argparse.ArgumentParser: setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover") setup.add_argument("--serial-a", required=True) setup.add_argument("--serial-b") + setup.add_argument("--serial-c", help="third phone for a three-party scenario") setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)") setup.add_argument("--apk", type=Path, default=None) setup.add_argument("--watch-apk", type=Path, default=None) setup.add_argument("--nickname-a", default="alice") setup.add_argument("--nickname-b", default="bob") + setup.add_argument("--nickname-c", default="charlie") - scenario = commands.add_parser("scenario", help="run a test scenario on two devices") - scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"]) + scenario = commands.add_parser("scenario", help="run a test scenario on two phones, or three for courier delivery") + scenario.add_argument("name", choices=[*SCENARIOS.keys(), *TRIAD_SCENARIOS.keys(), "all"]) scenario.add_argument("--serial-a", required=True) scenario.add_argument("--serial-b") + scenario.add_argument("--serial-c", help="third phone; required for courier_delivery") scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)") scenario.add_argument("--out", type=Path, default=None, help="evidence output directory") @@ -972,30 +1372,36 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]: +def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device, Device | None]: """Device A is always the phone; device B is a watch when --serial-watch is given.""" a = Device(args.serial_a, "alpha") if getattr(args, "serial_watch", None): - return a, WatchDevice(args.serial_watch) + if getattr(args, "serial_c", None): + raise MeshLabError("--serial-c cannot be combined with --serial-watch") + return a, WatchDevice(args.serial_watch), None if not getattr(args, "serial_b", None): raise MeshLabError("either --serial-b or --serial-watch is required") - return a, Device(args.serial_b, "beta") + c = Device(args.serial_c, "charlie") if getattr(args, "serial_c", None) else None + return a, Device(args.serial_b, "beta"), c def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if args.command == "setup": - a, b = _resolve_devices(args) + a, b, c = _resolve_devices(args) nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b - setup_pair( - a, b, args.apk, args.nickname_a, nickname_b, - apk_b=args.watch_apk if isinstance(b, WatchDevice) else None, - ) + if c is not None: + setup_triad(a, b, c, args.apk, args.nickname_a, nickname_b, args.nickname_c) + else: + setup_pair( + a, b, args.apk, args.nickname_a, nickname_b, + apk_b=args.watch_apk if isinstance(b, WatchDevice) else None, + ) print(json.dumps({"status": "ok", "step": "setup"})) elif args.command == "scenario": - a, b = _resolve_devices(args) - evidence = run_scenario(args.name, a, b, args.out) + a, b, c = _resolve_devices(args) + evidence = run_scenario(args.name, a, b, args.out, c) print(json.dumps(evidence, indent=2, default=str)) return 0 if evidence["status"] == "pass" else 1 elif args.command == "cmd": diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index a4d37f68..ca16d5a0 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -81,6 +81,10 @@ composeCompiler { // onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice // features, and the phone's foreground service. val sharedSourceIncludes = listOf( + "com/bitchat/android/nostr/MeshMessageIdentity.kt", + "com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt", + "com/bitchat/android/board/BoardPackets.kt", + "com/bitchat/android/board/BoardStore.kt", "com/bitchat/android/protocol/**", "com/bitchat/android/noise/**", "com/bitchat/android/crypto/**", @@ -112,6 +116,7 @@ val sharedSourceIncludes = listOf( "com/bitchat/android/util/BinaryEncodingUtils.kt", ) val sharedSourceExcludes = listOf( + "com/bitchat/android/model/NostrCarrierPacket.kt", "com/bitchat/android/model/FileSharingManager.kt", // Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service // (MeshCore-style) in M2 instead of reusing these.