mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
fix: harden bridge courier privacy and retries
This commit is contained in:
parent
a9f471faea
commit
1e44e94ed4
@ -52,6 +52,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
|
||||
private var lastLocation: Location? = null
|
||||
private var geocodingJob: Job? = null
|
||||
@Volatile
|
||||
private var acceptLocationUpdates = true
|
||||
@Volatile
|
||||
private var locationRequestGeneration = 0L
|
||||
private val gson = Gson()
|
||||
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||
|
||||
@ -75,7 +79,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
private val locationUpdateCallback: (Location) -> Unit = { location ->
|
||||
onLocationUpdated(location)
|
||||
if (acceptLocationUpdates) onLocationUpdated(location)
|
||||
}
|
||||
|
||||
// Published state for UI bindings (matching iOS @Published properties)
|
||||
@ -166,9 +170,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 (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
|
||||
requestOneShotLocation()
|
||||
requestOneShotLocation(forceFresh, updatePlaceNames)
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,6 +196,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
acceptLocationUpdates = true
|
||||
// Register for continuous updates from available provider
|
||||
locationProvider.requestLocationUpdates(
|
||||
intervalMs = interval,
|
||||
@ -212,7 +220,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Select a channel
|
||||
*/
|
||||
fun select(channel: ChannelID) {
|
||||
Log.d(TAG, "Selected channel: ${channel.displayName}")
|
||||
Log.d(TAG, "Location channel selection updated")
|
||||
// Use synchronous set to avoid race with background recomputation
|
||||
_selectedChannel.value = channel
|
||||
saveChannelSelection(channel)
|
||||
@ -231,7 +239,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
val isTeleportedNow = currentGeohash != channel.channel.geohash
|
||||
_teleported.value = isTeleportedNow
|
||||
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
|
||||
Log.d(TAG, "Teleported status updated: $isTeleportedNow")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -292,7 +300,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 (!checkAndSyncPermission()) {
|
||||
Log.w(TAG, "No location permission for one-shot request")
|
||||
return
|
||||
@ -301,33 +312,53 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.d(TAG, "Requesting one-shot location")
|
||||
// Set loading state initially
|
||||
_isLoadingLocation.value = true
|
||||
acceptLocationUpdates = true
|
||||
val generation = locationRequestGeneration
|
||||
|
||||
if (forceFresh) {
|
||||
requestFreshLocation(updatePlaceNames, generation)
|
||||
return
|
||||
}
|
||||
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
if (generation != locationRequestGeneration || !acceptLocationUpdates) {
|
||||
return@getLastKnownLocation
|
||||
}
|
||||
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
|
||||
// For now, we just use it if it exists, similar to previous logic
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "Using last known location: ${cached.latitude}, ${cached.longitude}")
|
||||
onLocationUpdated(cached)
|
||||
Log.d(TAG, "Using recent location fix")
|
||||
onLocationUpdated(cached, updatePlaceNames)
|
||||
} else {
|
||||
Log.d(TAG, "No last known location available, requesting fresh...")
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (fresh != null) {
|
||||
Log.d(TAG, "Fresh location received: ${fresh.latitude}, ${fresh.longitude}")
|
||||
onLocationUpdated(fresh)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
requestFreshLocation(updatePlaceNames, generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(location: Location) {
|
||||
private fun requestFreshLocation(
|
||||
updatePlaceNames: Boolean = true,
|
||||
generation: Long = locationRequestGeneration
|
||||
) {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (generation != locationRequestGeneration || !acceptLocationUpdates) {
|
||||
return@requestFreshLocation
|
||||
}
|
||||
if (fresh != null) {
|
||||
Log.d(TAG, "Fresh location fix received")
|
||||
onLocationUpdated(fresh, updatePlaceNames)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(location: Location, updatePlaceNames: Boolean = true) {
|
||||
lastLocation = location
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
if (updatePlaceNames) reverseGeocodeIfNeeded(location)
|
||||
}
|
||||
|
||||
|
||||
@ -356,7 +387,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun computeChannels(location: Location) {
|
||||
Log.d(TAG, "Computing channels for location: ${location.latitude}, ${location.longitude}")
|
||||
Log.d(TAG, "Computing location channels")
|
||||
|
||||
val levels = GeohashChannelLevel.allCases()
|
||||
val result = mutableListOf<GeohashChannel>()
|
||||
@ -369,7 +400,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
result.add(GeohashChannel(level = level, geohash = geohash))
|
||||
|
||||
Log.v(TAG, "Generated ${level.displayName}: $geohash")
|
||||
Log.v(TAG, "Generated ${level.displayName} location channel")
|
||||
}
|
||||
|
||||
_availableChannels.value = result
|
||||
@ -388,7 +419,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
||||
_teleported.value = isTeleported
|
||||
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
|
||||
Log.d(TAG, "Teleported status updated: $isTeleported")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -408,7 +439,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
if (addresses.isNotEmpty()) {
|
||||
val address = addresses[0]
|
||||
val names = namesByLevel(address)
|
||||
Log.d(TAG, "Reverse geocoding result: $names")
|
||||
Log.d(TAG, "Reverse geocoding completed")
|
||||
_locationNames.value = names
|
||||
} else {
|
||||
Log.w(TAG, "No reverse geocoding results")
|
||||
@ -482,7 +513,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
}
|
||||
dataManager?.saveLastGeohashChannel(channelData)
|
||||
Log.d(TAG, "Saved channel selection: ${channel.displayName}")
|
||||
Log.d(TAG, "Saved location channel selection")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save channel selection: ${e.message}")
|
||||
}
|
||||
@ -499,7 +530,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val channel = persisted?.toChannel()
|
||||
if (channel != null) {
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
Log.d(TAG, "Restored persisted location channel")
|
||||
} else {
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
@ -543,6 +574,20 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.d(TAG, "Cleared persisted channel selection")
|
||||
}
|
||||
|
||||
/** Remove exact/transient location state without destroying the singleton. */
|
||||
fun panicReset() {
|
||||
acceptLocationUpdates = false
|
||||
locationRequestGeneration += 1
|
||||
locationProvider.cancel()
|
||||
geocodingJob?.cancel()
|
||||
geocodingJob = null
|
||||
lastLocation = null
|
||||
_availableChannels.value = emptyList()
|
||||
_locationNames.value = emptyMap()
|
||||
_isLoadingLocation.value = false
|
||||
clearPersistedChannel()
|
||||
}
|
||||
|
||||
// MARK: - Location Services State Persistence
|
||||
|
||||
/**
|
||||
|
||||
@ -413,7 +413,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)
|
||||
@ -570,6 +570,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) }
|
||||
@ -925,7 +928,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = false)
|
||||
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true)
|
||||
}
|
||||
|
||||
fun sendPrekeyBundle(payload: ByteArray) {
|
||||
@ -948,7 +951,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
ttl = MAX_TTL
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign && outgoing.signature?.size != 64) return@launch
|
||||
if (sign &&
|
||||
type != MessageType.COURIER_ENVELOPE &&
|
||||
outgoing.signature?.size != 64
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
broadcastRoutedPacket(RoutedPacket(outgoing))
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ interface BridgeMeshDelegate {
|
||||
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
|
||||
fun handlePrekeyPacket(packet: BitchatPacket)
|
||||
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
|
||||
fun handleCourierEnvelope(payload: ByteArray)
|
||||
fun handleCourierEnvelope(packet: BitchatPacket, fromPeerId: String, directIngress: Boolean)
|
||||
}
|
||||
|
||||
object BridgeMeshPort : BridgeMeshDelegate {
|
||||
@ -96,7 +96,11 @@ object BridgeMeshPort : BridgeMeshDelegate {
|
||||
delegate?.handleCarrier(payload, fromPeerId, directedToUs)
|
||||
}
|
||||
|
||||
override fun handleCourierEnvelope(payload: ByteArray) {
|
||||
delegate?.handleCourierEnvelope(payload)
|
||||
override fun handleCourierEnvelope(
|
||||
packet: BitchatPacket,
|
||||
fromPeerId: String,
|
||||
directIngress: Boolean
|
||||
) {
|
||||
delegate?.handleCourierEnvelope(packet, fromPeerId, directIngress)
|
||||
}
|
||||
}
|
||||
|
||||
@ -442,6 +442,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) }
|
||||
}
|
||||
@ -554,7 +557,7 @@ class MeshCore(
|
||||
type = MessageType.COURIER_ENVELOPE,
|
||||
payload = payload,
|
||||
recipientPeerID = recipientPeerID,
|
||||
sign = false
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
@ -583,7 +586,12 @@ class MeshCore(
|
||||
ttl = maxTtl
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign && outgoing.signature?.size != 64) return@launch
|
||||
if (sign &&
|
||||
type != MessageType.COURIER_ENVELOPE &&
|
||||
outgoing.signature?.size != 64
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
dispatchGlobal(RoutedPacket(outgoing))
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,7 +171,14 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
|
||||
MessageType.COURIER_ENVELOPE -> {
|
||||
BridgeMeshPort.handleCourierEnvelope(packet.payload)
|
||||
val directIngress =
|
||||
packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS &&
|
||||
delegate?.isPeerDirectlyConnected(peerID) == true
|
||||
BridgeMeshPort.handleCourierEnvelope(
|
||||
packet = packet,
|
||||
fromPeerId = peerID,
|
||||
directIngress = directIngress
|
||||
)
|
||||
}
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
@ -335,6 +342,7 @@ interface PacketProcessorDelegate {
|
||||
// Network information
|
||||
fun getNetworkSize(): Int
|
||||
fun getBroadcastRecipient(): ByteArray
|
||||
fun isPeerDirectlyConnected(peerID: String): Boolean = false
|
||||
|
||||
// Message type handlers
|
||||
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
|
||||
|
||||
@ -102,17 +102,17 @@ object AppStateStore {
|
||||
_publicMessages.value.any { !it.isBridged && it.id == messageId }
|
||||
}
|
||||
|
||||
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
|
||||
fun addPrivateMessage(peerID: String, msg: BitchatMessage): Boolean =
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
if (seenMessageIds.contains(msg.id)) return@synchronized false
|
||||
seenMessageIds.add(msg.id)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val list = (map[conversationID] ?: emptyList()) + msg
|
||||
map[conversationID] = list
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
null -> 0
|
||||
|
||||
@ -5,12 +5,15 @@ import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoriteControlMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.nostr.NostrTransport
|
||||
import com.bitchat.android.services.bridge.CourierDepositResult
|
||||
import com.bitchat.android.services.bridge.MeshBridgeService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@ -19,12 +22,21 @@ import kotlinx.coroutines.launch
|
||||
class MessageRouter private constructor(
|
||||
private var mesh: MeshService,
|
||||
private val nostr: NostrTransport,
|
||||
private val relayManager: NostrRelayManager,
|
||||
private val currentNostrIdentity: () -> com.bitchat.android.nostr.NostrIdentity?
|
||||
) {
|
||||
private data class OutboxMessage(
|
||||
val content: String,
|
||||
val recipientNickname: String,
|
||||
val messageId: String
|
||||
val messageId: String,
|
||||
val createdAtMs: Long = System.currentTimeMillis(),
|
||||
var lastMeshAttemptMs: Long = 0,
|
||||
var lastNostrAttemptMs: Long = 0
|
||||
)
|
||||
|
||||
private data class OutboxKey(
|
||||
val conversationID: String,
|
||||
val messageID: String
|
||||
)
|
||||
|
||||
enum class RouteResult {
|
||||
@ -36,6 +48,10 @@ class MessageRouter private constructor(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageRouter"
|
||||
private const val RETRY_INTERVAL_MS = 60_000L
|
||||
private const val TRANSPORT_RETRY_INTERVAL_MS = 5L * 60 * 1000
|
||||
private const val OUTBOX_TTL_MS = 48L * 60 * 60 * 1000
|
||||
private const val MAX_OUTBOX_PER_CONVERSATION = 50
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||
@ -46,6 +62,7 @@ class MessageRouter private constructor(
|
||||
MessageRouter(
|
||||
mesh = mesh,
|
||||
nostr = nostr,
|
||||
relayManager = NostrRelayManager.getInstance(application),
|
||||
currentNostrIdentity = {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge
|
||||
.getCurrentNostrIdentity(application)
|
||||
@ -67,7 +84,31 @@ class MessageRouter private constructor(
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val outboxLock = Any()
|
||||
private val outbox = mutableMapOf<String, MutableList<OutboxMessage>>()
|
||||
private val courierAttemptTimes = mutableMapOf<OutboxKey, Long>()
|
||||
private val courierAttemptsInFlight = mutableSetOf<OutboxKey>()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
MeshBridgeService.isEnabled.collect { enabled ->
|
||||
if (enabled) retryCourierDeposits(force = true)
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
relayManager.isConnected.collect { connected ->
|
||||
if (connected) flushAllOutbox()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(RETRY_INTERVAL_MS)
|
||||
pruneExpiredOutbox()
|
||||
retryCourierDeposits(force = false)
|
||||
if (relayManager.isConnected.value) flushAllOutbox()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||
@ -102,33 +143,26 @@ class MessageRouter private constructor(
|
||||
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…")
|
||||
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
|
||||
return RouteResult.MESH
|
||||
} else if (canSendViaNostr(nostrTarget)) {
|
||||
} else if (canSendViaNostr(nostrTarget) && relayManager.isConnected.value) {
|
||||
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
|
||||
} else {
|
||||
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
|
||||
val q = outbox.getOrPut(conversationID) { mutableListOf() }
|
||||
q.add(OutboxMessage(content, recipientNickname, messageID))
|
||||
Log.d(TAG, "Queued PM for ${conversationID.take(16)}… (no ready transport) msg_id=${messageID.take(8)}…")
|
||||
enqueueOutbox(
|
||||
conversationID,
|
||||
OutboxMessage(content, recipientNickname, messageID)
|
||||
)
|
||||
resolution.noisePublicKey?.let { recipientNoiseKey ->
|
||||
scope.launch {
|
||||
val result = runCatching {
|
||||
MeshBridgeService.depositCourierDrop(
|
||||
content = content,
|
||||
messageId = messageID,
|
||||
recipientNoiseKey = recipientNoiseKey
|
||||
)
|
||||
}.getOrElse { error ->
|
||||
Log.w(TAG, "Courier deposit failed: ${error.message}")
|
||||
return@launch
|
||||
}
|
||||
if (result is CourierDepositResult.Rejected) {
|
||||
Log.d(TAG, "Courier deposit rejected: ${result.reason}")
|
||||
}
|
||||
}
|
||||
attemptCourierDeposit(
|
||||
conversationID,
|
||||
OutboxMessage(content, recipientNickname, messageID),
|
||||
recipientNoiseKey,
|
||||
force = true
|
||||
)
|
||||
}
|
||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
|
||||
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
|
||||
if (hasMesh) mesh.initiateNoiseHandshake(meshTarget)
|
||||
return RouteResult.QUEUED
|
||||
}
|
||||
}
|
||||
@ -191,42 +225,85 @@ class MessageRouter private constructor(
|
||||
// Flush any queued messages for a specific peerID
|
||||
fun flushOutboxFor(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
|
||||
if (queued.isEmpty()) return
|
||||
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val queuedMessage = iterator.next()
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val meshTarget = resolution.meshPeerID
|
||||
val nostrTarget = resolution.noiseKeyHex ?: conversationID
|
||||
if (meshTarget != null && isReady(mesh, meshTarget)) {
|
||||
mesh.sendPrivateMessage(
|
||||
queuedMessage.content,
|
||||
meshTarget,
|
||||
queuedMessage.recipientNickname,
|
||||
queuedMessage.messageId
|
||||
)
|
||||
iterator.remove()
|
||||
} else if (canSendViaNostr(nostrTarget)) {
|
||||
nostr.sendPrivateMessage(
|
||||
queuedMessage.content,
|
||||
nostrTarget,
|
||||
queuedMessage.recipientNickname,
|
||||
queuedMessage.messageId
|
||||
)
|
||||
iterator.remove()
|
||||
}
|
||||
val matchingQueues = synchronized(outboxLock) {
|
||||
outbox.entries
|
||||
.filter { (key, _) ->
|
||||
key == peerID ||
|
||||
ContactDirectory.canonicalConversationId(key) == conversationID
|
||||
}
|
||||
.map { it.key to it.value.toList() }
|
||||
}
|
||||
if (queued.isEmpty()) {
|
||||
outbox.remove(conversationID)
|
||||
outbox.remove(peerID)
|
||||
if (matchingQueues.isEmpty()) return
|
||||
Log.d(
|
||||
TAG,
|
||||
"Flushing outbox for ${conversationID.take(16)}… count=${matchingQueues.sumOf { it.second.size }}"
|
||||
)
|
||||
matchingQueues.forEach { (queuedConversationID, queued) ->
|
||||
queued.forEach messageLoop@{ queuedMessage ->
|
||||
val resolution = ContactDirectory.resolve(queuedConversationID)
|
||||
val meshTarget = resolution.meshPeerID
|
||||
val nostrTarget = resolution.noiseKeyHex ?: queuedConversationID
|
||||
if (meshTarget != null && isReady(mesh, meshTarget)) {
|
||||
if (!shouldAttemptTransport(queuedMessage, mesh = true)) return@messageLoop
|
||||
mesh.sendPrivateMessage(
|
||||
queuedMessage.content,
|
||||
meshTarget,
|
||||
queuedMessage.recipientNickname,
|
||||
queuedMessage.messageId
|
||||
)
|
||||
recordTransportAttempt(
|
||||
queuedConversationID,
|
||||
queuedMessage.messageId,
|
||||
mesh = true
|
||||
)
|
||||
} else if (canSendViaNostr(nostrTarget) && relayManager.isConnected.value) {
|
||||
if (!shouldAttemptTransport(queuedMessage, mesh = false)) return@messageLoop
|
||||
nostr.sendPrivateMessage(
|
||||
queuedMessage.content,
|
||||
nostrTarget,
|
||||
queuedMessage.recipientNickname,
|
||||
queuedMessage.messageId
|
||||
)
|
||||
recordTransportAttempt(
|
||||
queuedConversationID,
|
||||
queuedMessage.messageId,
|
||||
mesh = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush everything (rarely used)
|
||||
fun flushAllOutbox() {
|
||||
outbox.keys.toList().forEach { flushOutboxFor(it) }
|
||||
synchronized(outboxLock) { outbox.keys.toList() }.forEach { flushOutboxFor(it) }
|
||||
}
|
||||
|
||||
/** Stop retrying a retained message once any transport confirms delivery. */
|
||||
fun acknowledge(messageID: String, fromPeerID: String) {
|
||||
val acknowledgedConversation = ContactDirectory.canonicalConversationId(fromPeerID)
|
||||
synchronized(outboxLock) {
|
||||
outbox.entries.forEach { (conversationID, messages) ->
|
||||
if (ContactDirectory.canonicalConversationId(conversationID) ==
|
||||
acknowledgedConversation
|
||||
) {
|
||||
messages.removeAll { it.messageId == messageID }
|
||||
val key = OutboxKey(conversationID, messageID)
|
||||
courierAttemptTimes.remove(key)
|
||||
courierAttemptsInFlight.remove(key)
|
||||
}
|
||||
}
|
||||
outbox.entries.removeAll { it.value.isEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Panic-mode hook: retained plaintext and retry metadata must not survive. */
|
||||
fun wipeOutbox() {
|
||||
synchronized(outboxLock) {
|
||||
outbox.clear()
|
||||
courierAttemptTimes.clear()
|
||||
courierAttemptsInFlight.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canSendViaNostr(peerID: String): Boolean {
|
||||
@ -273,6 +350,7 @@ class MessageRouter private constructor(
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let { flushOutboxFor(it) }
|
||||
}
|
||||
retryCourierDeposits(force = true)
|
||||
}
|
||||
|
||||
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
|
||||
@ -283,4 +361,126 @@ class MessageRouter private constructor(
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let { flushOutboxFor(it) }
|
||||
}
|
||||
|
||||
private fun enqueueOutbox(conversationID: String, message: OutboxMessage) {
|
||||
synchronized(outboxLock) {
|
||||
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
|
||||
if (queue.any { it.messageId == message.messageId }) return
|
||||
queue += message
|
||||
while (queue.size > MAX_OUTBOX_PER_CONVERSATION) {
|
||||
val removed = queue.removeAt(0)
|
||||
val key = OutboxKey(conversationID, removed.messageId)
|
||||
courierAttemptTimes.remove(key)
|
||||
courierAttemptsInFlight.remove(key)
|
||||
AppStateStore.updatePrivateMessageStatus(
|
||||
removed.messageId,
|
||||
com.bitchat.android.model.DeliveryStatus.Failed("delivery queue full")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun retryCourierDeposits(force: Boolean) {
|
||||
val queued = synchronized(outboxLock) {
|
||||
outbox.flatMap { (conversationID, messages) ->
|
||||
messages.filter { !isExpired(it) }.map { conversationID to it }
|
||||
}
|
||||
}
|
||||
queued.forEach { (conversationID, message) ->
|
||||
val recipientNoiseKey =
|
||||
ContactDirectory.resolve(conversationID).noisePublicKey
|
||||
if (recipientNoiseKey != null) {
|
||||
attemptCourierDeposit(conversationID, message, recipientNoiseKey, force)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptCourierDeposit(
|
||||
conversationID: String,
|
||||
message: OutboxMessage,
|
||||
recipientNoiseKey: ByteArray,
|
||||
force: Boolean
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val key = OutboxKey(conversationID, message.messageId)
|
||||
synchronized(outboxLock) {
|
||||
if (key in courierAttemptsInFlight) return
|
||||
val lastAttempt = courierAttemptTimes[key] ?: 0L
|
||||
if (!force && now - lastAttempt < RETRY_INTERVAL_MS) return
|
||||
courierAttemptTimes[key] = now
|
||||
courierAttemptsInFlight += key
|
||||
}
|
||||
scope.launch {
|
||||
val result = runCatching {
|
||||
MeshBridgeService.depositCourierDrop(
|
||||
content = message.content,
|
||||
messageId = message.messageId,
|
||||
recipientNoiseKey = recipientNoiseKey
|
||||
)
|
||||
}.getOrElse { error ->
|
||||
Log.w(TAG, "Courier deposit failed: ${error.message}")
|
||||
null
|
||||
}
|
||||
synchronized(outboxLock) {
|
||||
courierAttemptsInFlight.remove(key)
|
||||
}
|
||||
if (result is CourierDepositResult.Rejected) {
|
||||
Log.d(TAG, "Courier deposit rejected: ${result.reason}")
|
||||
} else if (
|
||||
result == CourierDepositResult.Published ||
|
||||
result == CourierDepositResult.ForwardedToGateway ||
|
||||
result == CourierDepositResult.AlreadyPublished
|
||||
) {
|
||||
AppStateStore.updatePrivateMessageStatus(
|
||||
message.messageId,
|
||||
com.bitchat.android.model.DeliveryStatus.Sent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneExpiredOutbox() {
|
||||
synchronized(outboxLock) {
|
||||
outbox.forEach { (conversationID, messages) ->
|
||||
val expiredIds = messages.filter(::isExpired).map { it.messageId }.toSet()
|
||||
messages.removeAll { it.messageId in expiredIds }
|
||||
expiredIds.forEach {
|
||||
val key = OutboxKey(conversationID, it)
|
||||
courierAttemptTimes.remove(key)
|
||||
courierAttemptsInFlight.remove(key)
|
||||
AppStateStore.updatePrivateMessageStatus(
|
||||
it,
|
||||
com.bitchat.android.model.DeliveryStatus.Failed("delivery expired")
|
||||
)
|
||||
}
|
||||
}
|
||||
outbox.entries.removeAll { it.value.isEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldAttemptTransport(message: OutboxMessage, mesh: Boolean): Boolean {
|
||||
val lastAttempt = synchronized(outboxLock) {
|
||||
if (mesh) message.lastMeshAttemptMs else message.lastNostrAttemptMs
|
||||
}
|
||||
return System.currentTimeMillis() - lastAttempt >= TRANSPORT_RETRY_INTERVAL_MS
|
||||
}
|
||||
|
||||
private fun recordTransportAttempt(
|
||||
conversationID: String,
|
||||
messageID: String,
|
||||
mesh: Boolean
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(outboxLock) {
|
||||
outbox[conversationID].orEmpty()
|
||||
.filter { it.messageId == messageID }
|
||||
.forEach { message ->
|
||||
if (mesh) message.lastMeshAttemptMs = now else message.lastNostrAttemptMs = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExpired(message: OutboxMessage): Boolean =
|
||||
System.currentTimeMillis() - message.createdAtMs >= OUTBOX_TTL_MS
|
||||
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ internal data class VerifiedBridgePeer(
|
||||
val nickname: String,
|
||||
val noiseKey: ByteArray,
|
||||
val signingKey: ByteArray,
|
||||
val isVerifiedNickname: Boolean,
|
||||
val capabilities: PeerCapabilities?,
|
||||
val bridgeCell: String?,
|
||||
val lastSeenMs: Long
|
||||
|
||||
@ -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<VerifiedBridgePeer>,
|
||||
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) }
|
||||
}
|
||||
@ -16,9 +16,11 @@ import com.bitchat.android.nostr.NostrKind
|
||||
import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrPublishResult
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.services.MessageRouter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -42,20 +44,25 @@ internal class CourierCoordinator(
|
||||
private val prekeys: PrekeyManager,
|
||||
private val meshProvider: () -> MeshService?,
|
||||
private val peersProvider: () -> List<VerifiedBridgePeer>,
|
||||
private val onPrekeyConsumed: () -> Unit,
|
||||
private val isTrustedDepositor: (VerifiedBridgePeer) -> Boolean,
|
||||
private val isSenderBlocked: (ByteArray) -> Boolean,
|
||||
private val onPrekeyConsumed: suspend () -> Unit,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
) {
|
||||
private data class PendingDrop(
|
||||
val envelope: CourierEnvelope,
|
||||
val dedupKey: String?,
|
||||
val queueKey: String
|
||||
val queueKey: String,
|
||||
val depositorKey: String? = null,
|
||||
val attemptedGatewayPeerIds: MutableSet<String> = mutableSetOf()
|
||||
)
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val pendingDrops = mutableListOf<PendingDrop>()
|
||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||
private val depositTimesByPeer = mutableMapOf<String, MutableList<Long>>()
|
||||
private var subscribedTags: Set<String> = emptySet()
|
||||
private val courierSubscription = RelaySubscriptionSlot("mesh-bridge-courier")
|
||||
@Volatile
|
||||
@ -82,7 +89,9 @@ internal class CourierCoordinator(
|
||||
|
||||
fun peerStateChanged() {
|
||||
scope.launch {
|
||||
if (enabled) refreshSubscription()
|
||||
if (!enabled) return@launch
|
||||
refreshSubscription()
|
||||
retryPendingGatewayHandoffs()
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,15 +103,25 @@ internal class CourierCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEnvelope(payload: ByteArray) {
|
||||
fun handleEnvelope(packet: BitchatPacket, fromPeerId: String, directIngress: Boolean) {
|
||||
scope.launch {
|
||||
val envelope = CourierEnvelope.decode(payload) ?: return@launch
|
||||
val envelope = CourierEnvelope.decode(packet.payload) ?: return@launch
|
||||
if (!validLifetime(envelope)) return@launch
|
||||
if (isMyTag(envelope.recipientTag)) {
|
||||
openEnvelope(envelope)
|
||||
} else if (enabled) {
|
||||
publishOrQueue(envelope, dedupKey = null)
|
||||
return@launch
|
||||
}
|
||||
if (!enabled) return@launch
|
||||
|
||||
if (!allowSignatureAttempt()) return@launch
|
||||
val depositor = authenticatedDepositor(packet, fromPeerId, directIngress)
|
||||
?: return@launch
|
||||
if (!allowDeposit(depositor.peerId)) return@launch
|
||||
publishOrQueue(
|
||||
envelope = envelope,
|
||||
dedupKey = null,
|
||||
depositorKey = depositor.peerId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,6 +196,16 @@ internal class CourierCoordinator(
|
||||
val gateway = availableGateway()
|
||||
if (gateway != null) {
|
||||
meshProvider()?.sendCourierEnvelope(encoded, gateway.peerId)
|
||||
// Keep a local copy until a relay accepts it. If the gateway is
|
||||
// lost before publishing, router retries can choose another path.
|
||||
enqueue(
|
||||
PendingDrop(
|
||||
envelope,
|
||||
dedupKey,
|
||||
dedupKey,
|
||||
attemptedGatewayPeerIds = mutableSetOf(gateway.peerId)
|
||||
)
|
||||
)
|
||||
return@withContext CourierDepositResult.ForwardedToGateway
|
||||
}
|
||||
enqueue(PendingDrop(envelope, dedupKey, dedupKey))
|
||||
@ -187,6 +216,7 @@ internal class CourierCoordinator(
|
||||
closeSubscription()
|
||||
pendingDrops.clear()
|
||||
signatureAttemptTimes.clear()
|
||||
depositTimesByPeer.clear()
|
||||
publishedDropKeys.clear()
|
||||
seenDropEventIds.clear()
|
||||
openedMessageIds.clear()
|
||||
@ -232,7 +262,7 @@ internal class CourierCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDropEvent(event: NostrEvent) {
|
||||
private suspend fun handleDropEvent(event: NostrEvent) {
|
||||
if (!enabled ||
|
||||
event.kind != NostrKind.COURIER_DROP ||
|
||||
seenDropEventIds.contains(event.id, clock()) ||
|
||||
@ -268,14 +298,19 @@ internal class CourierCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun openEnvelope(envelope: CourierEnvelope): Boolean {
|
||||
private suspend fun openEnvelope(envelope: CourierEnvelope): Boolean {
|
||||
val opened = runCatching {
|
||||
prekeys.open(envelope.ciphertext, envelope.prekeyId, clock())
|
||||
}.getOrNull() ?: return false
|
||||
// Consumption happened during open. Announce the reduced/replenished
|
||||
// bundle before any later payload parser can return early.
|
||||
if (opened.consumedPrekey) onPrekeyConsumed()
|
||||
val payload = NoisePayload.decode(opened.payload) ?: return true
|
||||
if (payload.type != NoisePayloadType.PRIVATE_MESSAGE) return true
|
||||
val privateMessage = PrivateMessagePacket.decode(payload.data) ?: return true
|
||||
if (openedMessageIds.contains(privateMessage.messageID, clock())) return true
|
||||
openedMessageIds.add(privateMessage.messageID, DROP_DEDUP_MS, clock())
|
||||
if (isSenderBlocked(opened.senderStaticKey)) return true
|
||||
val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey)
|
||||
val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex())
|
||||
val message = BitchatMessage(
|
||||
@ -289,18 +324,23 @@ internal class CourierCoordinator(
|
||||
recipientNickname = meshProvider()?.myPeerID,
|
||||
senderPeerID = senderPeerId
|
||||
)
|
||||
AppStateStore.addPrivateMessage(
|
||||
val delivered = AppStateStore.addPrivateMessage(
|
||||
ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey),
|
||||
message
|
||||
)
|
||||
openedMessageIds.add(privateMessage.messageID, DROP_DEDUP_MS, clock())
|
||||
if (opened.consumedPrekey) onPrekeyConsumed()
|
||||
if (!delivered) return true
|
||||
CourierMessagePort.deliver(message)
|
||||
MessageRouter.tryGetInstance()?.sendDeliveryAck(
|
||||
privateMessage.messageID,
|
||||
opened.senderStaticKey.toHex()
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun publishOrQueue(
|
||||
envelope: CourierEnvelope,
|
||||
dedupKey: String?
|
||||
dedupKey: String?,
|
||||
depositorKey: String? = null
|
||||
): CourierDepositResult {
|
||||
if (!validLifetime(envelope)) {
|
||||
return CourierDepositResult.Rejected(CourierDepositResult.Reason.INVALID_MESSAGE)
|
||||
@ -310,7 +350,8 @@ internal class CourierCoordinator(
|
||||
PendingDrop(
|
||||
envelope,
|
||||
dedupKey,
|
||||
dedupKey ?: envelopeQueueKey(envelope)
|
||||
dedupKey ?: envelopeQueueKey(envelope),
|
||||
depositorKey
|
||||
)
|
||||
)
|
||||
return CourierDepositResult.QueuedLocally
|
||||
@ -352,7 +393,8 @@ internal class CourierCoordinator(
|
||||
PendingDrop(
|
||||
envelope,
|
||||
dedupKey,
|
||||
dedupKey ?: envelopeQueueKey(envelope)
|
||||
dedupKey ?: envelopeQueueKey(envelope),
|
||||
depositorKey
|
||||
)
|
||||
)
|
||||
CourierDepositResult.QueuedLocally
|
||||
@ -361,16 +403,38 @@ internal class CourierCoordinator(
|
||||
}
|
||||
|
||||
private fun enqueue(drop: PendingDrop) {
|
||||
if (pendingDrops.any { it.queueKey == drop.queueKey }) return
|
||||
pendingDrops.firstOrNull { it.queueKey == drop.queueKey }?.let { existing ->
|
||||
existing.attemptedGatewayPeerIds += drop.attemptedGatewayPeerIds
|
||||
return
|
||||
}
|
||||
if (drop.depositorKey != null &&
|
||||
pendingDrops.count { it.depositorKey == drop.depositorKey } >=
|
||||
MAX_PENDING_DROPS_PER_DEPOSITOR
|
||||
) {
|
||||
return
|
||||
}
|
||||
pendingDrops += drop
|
||||
while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0)
|
||||
}
|
||||
|
||||
private fun retryPendingGatewayHandoffs() {
|
||||
if (relayManager.isConnected.value) return
|
||||
pendingDrops.forEach { drop ->
|
||||
if (drop.depositorKey != null) return@forEach
|
||||
if (drop.attemptedGatewayPeerIds.size >= MAX_GATEWAYS_PER_DROP) return@forEach
|
||||
val gateway = availableGateway(excluding = drop.attemptedGatewayPeerIds)
|
||||
?: return@forEach
|
||||
val encoded = drop.envelope.encode() ?: return@forEach
|
||||
meshProvider()?.sendCourierEnvelope(encoded, gateway.peerId)
|
||||
drop.attemptedGatewayPeerIds += gateway.peerId
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushPendingDrops() {
|
||||
if (!enabled || !relayManager.isConnected.value) return
|
||||
val queued = pendingDrops.toList()
|
||||
pendingDrops.clear()
|
||||
queued.forEach { publishOrQueue(it.envelope, it.dedupKey) }
|
||||
queued.forEach { publishOrQueue(it.envelope, it.dedupKey, it.depositorKey) }
|
||||
}
|
||||
|
||||
private fun closeSubscription() {
|
||||
@ -378,11 +442,15 @@ internal class CourierCoordinator(
|
||||
subscribedTags = emptySet()
|
||||
}
|
||||
|
||||
private fun availableGateway(): VerifiedBridgePeer? =
|
||||
private fun availableGateway(excluding: Set<String> = emptySet()): VerifiedBridgePeer? =
|
||||
peersProvider().firstOrNull { peer ->
|
||||
peer.peerId !in excluding &&
|
||||
peer.capabilities?.contains(com.bitchat.android.model.PeerCapabilities.BRIDGE) == true &&
|
||||
peer.bridgeCell != null &&
|
||||
meshProvider()?.getPeerInfo(peer.peerId)?.isConnected == true
|
||||
meshProvider()?.getPeerInfo(peer.peerId)?.let { info ->
|
||||
info.isConnected && info.isDirectConnection
|
||||
} == true &&
|
||||
isTrustedDepositor(peer)
|
||||
}
|
||||
|
||||
private fun isMyTag(tag: ByteArray): Boolean {
|
||||
@ -390,6 +458,27 @@ internal class CourierCoordinator(
|
||||
return CourierEnvelope.candidateTags(ownKey, clock()).any { it.contentEquals(tag) }
|
||||
}
|
||||
|
||||
private fun authenticatedDepositor(
|
||||
packet: BitchatPacket,
|
||||
fromPeerId: String,
|
||||
directIngress: Boolean
|
||||
): VerifiedBridgePeer? = CourierDepositAuthenticator.authenticate(
|
||||
packet = packet,
|
||||
fromPeerId = fromPeerId,
|
||||
directIngress = directIngress,
|
||||
peers = peersProvider(),
|
||||
isTrusted = isTrustedDepositor
|
||||
)
|
||||
|
||||
private fun allowDeposit(peerId: String): Boolean {
|
||||
val now = clock()
|
||||
val attempts = depositTimesByPeer.getOrPut(peerId) { mutableListOf() }
|
||||
attempts.removeAll { now - it >= RATE_WINDOW_MS }
|
||||
if (attempts.size >= DEPOSITS_PER_MINUTE_PER_PEER) return false
|
||||
attempts += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun validLifetime(envelope: CourierEnvelope): Boolean {
|
||||
val now = clock()
|
||||
return !envelope.isExpired(now) &&
|
||||
@ -423,10 +512,13 @@ internal class CourierCoordinator(
|
||||
const val MAX_TRACKED_IDS = 512
|
||||
const val MAX_WATCHED_PEERS = 16
|
||||
const val MAX_PENDING_DROPS = 20
|
||||
const val MAX_PENDING_DROPS_PER_DEPOSITOR = 5
|
||||
const val MAX_GATEWAYS_PER_DROP = 2
|
||||
const val MAX_DROP_BYTES = 20 * 1024
|
||||
const val MAX_PRIVATE_MESSAGE_BYTES = 255
|
||||
const val DROP_DEDUP_MS = 24L * 60 * 60 * 1000
|
||||
const val RATE_WINDOW_MS = 60_000L
|
||||
const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720
|
||||
const val DEPOSITS_PER_MINUTE_PER_PEER = 10
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* Process-local delivery signal for courier-opened private messages.
|
||||
*
|
||||
* AppStateStore remains the durable in-process timeline source. This signal
|
||||
* lets the active UI run the same unread, haptic, and notification path used
|
||||
* by live mesh private messages without retaining an Activity or ViewModel.
|
||||
*/
|
||||
object CourierMessagePort {
|
||||
private val _messages = MutableSharedFlow<BitchatMessage>(
|
||||
extraBufferCapacity = 16,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val messages: SharedFlow<BitchatMessage> = _messages.asSharedFlow()
|
||||
|
||||
internal fun deliver(message: BitchatMessage) {
|
||||
_messages.tryEmit(message)
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
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
|
||||
@ -22,6 +24,8 @@ 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
|
||||
@ -118,6 +122,10 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||
private var downlinkJob: Job? = null
|
||||
private var presenceJob: Job? = null
|
||||
@Volatile
|
||||
private var suppressPrekeyBroadcasts = false
|
||||
@Volatile
|
||||
private var panicQuiesced = false
|
||||
|
||||
fun initialize(
|
||||
context: Context,
|
||||
@ -138,7 +146,11 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
relayManager = NostrRelayManager.getInstance(application)
|
||||
val prekeyManager = PrekeyManager.getInstance(application)
|
||||
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
_isEnabled.value = loadEnabledWithMigration(prefs!!)
|
||||
val storedEnabled = loadEnabledWithMigration(prefs!!)
|
||||
_isEnabled.value = storedEnabled && !panicQuiesced
|
||||
if (panicQuiesced && storedEnabled) {
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, false) }
|
||||
}
|
||||
outboundPolicy.set(
|
||||
BridgeOutboundPolicy.capture(
|
||||
enabled = _isEnabled.value,
|
||||
@ -159,8 +171,23 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
prekeys = prekeyManager,
|
||||
meshProvider = ::currentMesh,
|
||||
peersProvider = verifiedPeerSnapshot::get,
|
||||
isTrustedDepositor = { peer ->
|
||||
runCatching {
|
||||
FavoritesPersistenceService.shared
|
||||
.getFavoriteStatus(peer.noiseKey)
|
||||
?.isMutual == true
|
||||
}.getOrDefault(false) || peer.isVerifiedNickname
|
||||
},
|
||||
isSenderBlocked = { noiseKey ->
|
||||
DataManager.isFingerprintBlocked(
|
||||
application,
|
||||
ContactIdentityResolver.fingerprintHex(noiseKey)
|
||||
)
|
||||
},
|
||||
onPrekeyConsumed = {
|
||||
scope.launch { prekeyCoordinator?.broadcast(force = true) }
|
||||
kotlinx.coroutines.withContext(dispatcher) {
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
},
|
||||
clock = clock
|
||||
)
|
||||
@ -170,6 +197,10 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
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
|
||||
@ -203,18 +234,25 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
scope.launch {
|
||||
if (_isEnabled.value) {
|
||||
relayManager?.connect()
|
||||
location.refreshChannels()
|
||||
location.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
courierCoordinator?.peerStateChanged()
|
||||
}
|
||||
startPresenceLoop()
|
||||
if (_isEnabled.value) startPresenceLoop()
|
||||
delay(2_000)
|
||||
prekeyCoordinator?.broadcast(force = true)
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
if (outboundPolicy.get().enabled == enabled) return
|
||||
if (enabled) {
|
||||
panicQuiesced = false
|
||||
suppressPrekeyBroadcasts = false
|
||||
}
|
||||
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
|
||||
_isEnabled.value = enabled
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||
@ -223,18 +261,25 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
courierCoordinator?.setEnabled(enabled)
|
||||
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()
|
||||
LocationChannelManager.getInstance(requireContext())
|
||||
.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
courierCoordinator?.peerStateChanged()
|
||||
prekeyCoordinator?.broadcast(force = true)
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
currentMesh()?.sendBroadcastAnnounce()
|
||||
}
|
||||
@ -290,7 +335,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
|
||||
/** Called only after a public radio packet's Ed25519 signature was accepted. */
|
||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
if (messageId.isBlank()) return
|
||||
if (panicQuiesced || messageId.isBlank()) return
|
||||
scope.launch {
|
||||
radioMessageIds.add(messageId)
|
||||
pendingDownlinks.removeAll { pending ->
|
||||
@ -300,12 +345,15 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
}
|
||||
|
||||
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()
|
||||
@ -318,15 +366,17 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
refreshRendezvous()
|
||||
courierCoordinator?.peerStateChanged()
|
||||
}
|
||||
prekeyCoordinator?.broadcast()
|
||||
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) {
|
||||
@ -342,8 +392,13 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleCourierEnvelope(payload: ByteArray) {
|
||||
courierCoordinator?.handleEnvelope(payload)
|
||||
override fun handleCourierEnvelope(
|
||||
packet: BitchatPacket,
|
||||
fromPeerId: String,
|
||||
directIngress: Boolean
|
||||
) {
|
||||
if (panicQuiesced) return
|
||||
courierCoordinator?.handleEnvelope(packet, fromPeerId, directIngress)
|
||||
}
|
||||
|
||||
suspend fun depositCourierDrop(
|
||||
@ -354,12 +409,27 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
courierCoordinator?.deposit(content, messageId, recipientNoiseKey)
|
||||
?: CourierDepositResult.Rejected(CourierDepositResult.Reason.BRIDGE_DISABLED)
|
||||
|
||||
@SuppressLint("ApplySharedPref", "UseKtx")
|
||||
suspend fun wipe() {
|
||||
courierCoordinator?.wipe()
|
||||
// Revoke policy synchronously so no already-queued bridge work can be
|
||||
// authorized by the pre-panic setting.
|
||||
outboundPolicy.set(BridgeOutboundPolicy.Denied)
|
||||
panicQuiesced = true
|
||||
suppressPrekeyBroadcasts = true
|
||||
_isEnabled.value = false
|
||||
_nearbyOnly.value = false
|
||||
PeerCapabilities.setBridgeEnabled(false)
|
||||
courierCoordinator?.setEnabled(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()
|
||||
@ -369,10 +439,15 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
rebroadcastEventIds.clear()
|
||||
injectedEventIds.clear()
|
||||
radioMessageIds.clear()
|
||||
uplinkTimes.clear()
|
||||
inboundTimes.clear()
|
||||
inboundTimesBySigner.clear()
|
||||
downlinkTimes.clear()
|
||||
signatureAttemptTimes.clear()
|
||||
prekeyCoordinator?.wipe()
|
||||
_nearbyOnly.value = false
|
||||
publishParticipants()
|
||||
}
|
||||
courierCoordinator?.wipe()
|
||||
}
|
||||
|
||||
private fun publishVerifiedPeerSnapshot() {
|
||||
@ -622,15 +697,30 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
delay(PRESENCE_INTERVAL_MS)
|
||||
pruneParticipants()
|
||||
if (_isEnabled.value) {
|
||||
LocationChannelManager.getInstance(requireContext())
|
||||
.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous()
|
||||
courierCoordinator?.peerStateChanged()
|
||||
publishPresence()
|
||||
prekeyCoordinator?.broadcast()
|
||||
broadcastPrekeys()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPresenceLoop() {
|
||||
presenceJob?.cancel()
|
||||
presenceJob = null
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@ -4,8 +4,6 @@ import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
|
||||
/**
|
||||
* Coordinates authenticated prekey packets without owning cryptographic
|
||||
@ -58,20 +56,10 @@ internal class PrekeyCoordinator(
|
||||
bundle: PrekeyBundle,
|
||||
peer: VerifiedBridgePeer
|
||||
) {
|
||||
val signature = packet.signature ?: return
|
||||
val signingData = packet.toBinaryDataForSigning() ?: return
|
||||
if (!verifyEd25519(signature, signingData, peer.signingKey)) return
|
||||
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return
|
||||
manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())
|
||||
}
|
||||
|
||||
private fun verifyEd25519(signature: ByteArray, data: ByteArray, key: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(false, Ed25519PublicKeyParameters(key, 0))
|
||||
update(data, 0, data.size)
|
||||
}.verifySignature(signature)
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private companion object {
|
||||
|
||||
@ -269,6 +269,15 @@ class ChatViewModel(
|
||||
} catch (_: Exception) { }
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
com.bitchat.android.services.bridge.CourierMessagePort.messages.collect { message ->
|
||||
// The coordinator already inserted the message into AppStateStore
|
||||
// after authenticating and block-checking its full Noise key.
|
||||
// Reuse normal mesh ingress for unread tracking, haptics, and
|
||||
// notification suppression while the conversation is focused.
|
||||
meshDelegateHandler.didReceiveMessage(message)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
|
||||
// Replace with store snapshot
|
||||
@ -992,6 +1001,13 @@ class ChatViewModel(
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
// become actionable against the fresh post-wipe identity.
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()?.wipeOutbox()
|
||||
|
||||
// Revoke all bridge publication/courier authority before clearing
|
||||
// UI, transport, or identity state.
|
||||
try {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.wipe()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Clear all UI managers
|
||||
messageManager.clearAllMessages()
|
||||
@ -1007,10 +1023,6 @@ class ChatViewModel(
|
||||
// Clear all mesh service data
|
||||
clearAllMeshServiceData()
|
||||
|
||||
try {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.wipe()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Clear all cryptographic data
|
||||
clearAllCryptographicData()
|
||||
|
||||
@ -1030,7 +1042,7 @@ class ChatViewModel(
|
||||
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
locationManager.clearPersistedChannel()
|
||||
locationManager.panicReset()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
geohashViewModel.panicReset()
|
||||
|
||||
@ -13,9 +13,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
|
||||
@ -198,12 +210,12 @@ class DataManager(private val context: Context) {
|
||||
// MARK: - Blocked Users Management
|
||||
|
||||
fun loadBlockedUsers() {
|
||||
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
|
||||
val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet()
|
||||
_blockedUsers.addAll(savedBlockedUsers)
|
||||
}
|
||||
|
||||
fun saveBlockedUsers() {
|
||||
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
|
||||
prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply()
|
||||
}
|
||||
|
||||
fun addBlockedUser(fingerprint: String) {
|
||||
|
||||
@ -155,6 +155,8 @@ class MeshDelegateHandler(
|
||||
|
||||
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
|
||||
coroutineScope.launch {
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()
|
||||
?.acknowledge(messageID, recipientPeerID)
|
||||
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ package com.bitchat.android.services
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
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 java.util.Date
|
||||
@ -174,6 +176,20 @@ class AppStateStoreTest {
|
||||
assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private message insertion reports duplicates before ingress side effects`() {
|
||||
val message = BitchatMessage(
|
||||
id = "same-message",
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(1)
|
||||
)
|
||||
|
||||
assertTrue(AppStateStore.addPrivateMessage("peer", message))
|
||||
assertFalse(AppStateStore.addPrivateMessage("peer", message))
|
||||
assertEquals(listOf(message), AppStateStore.privateMessages.value["peer"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonicalized private chat history is chronological after alias merge`() {
|
||||
val noiseKeyHex = "01".repeat(32)
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
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.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CourierDepositAuthenticatorTest {
|
||||
private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { (it + 1).toByte() }, 0)
|
||||
private val peerId = "0011223344556677"
|
||||
private val peer = VerifiedBridgePeer(
|
||||
peerId = peerId,
|
||||
nickname = "peer",
|
||||
noiseKey = ByteArray(32) { 0x22 },
|
||||
signingKey = privateKey.generatePublicKey().encoded,
|
||||
isVerifiedNickname = true,
|
||||
capabilities = null,
|
||||
bridgeCell = "u4pruy",
|
||||
lastSeenMs = 1
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `direct trusted announce-bound depositor is accepted`() {
|
||||
assertNotNull(authenticate(signedPacket()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relayed or claimed-sender mismatch is rejected`() {
|
||||
assertNull(authenticate(signedPacket(), directIngress = false))
|
||||
assertNull(authenticate(signedPacket(), fromPeerId = "8899aabbccddeeff"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `untrusted or tampered depositor is rejected`() {
|
||||
assertNull(authenticate(signedPacket(), trusted = false))
|
||||
assertNull(authenticate(signedPacket().copy(payload = "tampered".toByteArray())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ttl relay mutation does not invalidate canonical iOS-compatible signature`() {
|
||||
assertNotNull(authenticate(signedPacket().copy(ttl = 6u)))
|
||||
}
|
||||
|
||||
private fun authenticate(
|
||||
packet: BitchatPacket,
|
||||
fromPeerId: String = peerId,
|
||||
directIngress: Boolean = true,
|
||||
trusted: Boolean = true
|
||||
): VerifiedBridgePeer? = CourierDepositAuthenticator.authenticate(
|
||||
packet = packet,
|
||||
fromPeerId = fromPeerId,
|
||||
directIngress = directIngress,
|
||||
peers = listOf(peer),
|
||||
isTrusted = { trusted }
|
||||
)
|
||||
|
||||
private fun signedPacket(): BitchatPacket {
|
||||
val unsigned = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = peerId.chunked(2).map { it.toInt(16).toByte() }.toByteArray(),
|
||||
recipientID = ByteArray(8) { 0x44 },
|
||||
timestamp = 1_750_000_000_000u,
|
||||
payload = "courier-envelope".toByteArray(),
|
||||
ttl = 7u
|
||||
)
|
||||
val data = requireNotNull(unsigned.toBinaryDataForSigning())
|
||||
val signature = Ed25519Signer().apply {
|
||||
init(true, privateKey)
|
||||
update(data, 0, data.size)
|
||||
}.generateSignature()
|
||||
return unsigned.copy(signature = signature)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user