mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
refactor: harden bridge delivery boundaries
This commit is contained in:
parent
b0f3bd34fc
commit
7800d4bca6
@ -60,6 +60,9 @@ class BitchatApplication : Application() {
|
||||
// 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) { }
|
||||
|
||||
// Proactively start the foreground service to keep mesh alive
|
||||
|
||||
@ -8,7 +8,6 @@ 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 +18,7 @@ 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.services.bridge.BridgeProtocolPacketFactory
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@ -908,7 +908,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val nickname = runCatching {
|
||||
com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID)
|
||||
}.getOrNull()
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.bridgeOutgoing(
|
||||
BridgeMeshPort.bridgeOutgoing(
|
||||
content,
|
||||
myPeerID,
|
||||
packet.timestamp.toLong(),
|
||||
@ -938,16 +938,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
) {
|
||||
if (payload.isEmpty()) return
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = if (payload.size > 0xFFFF) 2u else 1u,
|
||||
type = type.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = recipientPeerID?.let(::hexStringToByteArray),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
val packet = BridgeProtocolPacketFactory.protocolPacket(
|
||||
type = type,
|
||||
payload = payload,
|
||||
signature = null,
|
||||
senderPeerId = myPeerID,
|
||||
recipientPeerId = recipientPeerID,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign && outgoing.signature?.size != 64) return@launch
|
||||
broadcastRoutedPacket(RoutedPacket(outgoing))
|
||||
@ -1286,11 +1283,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey,
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell()
|
||||
signingKey
|
||||
)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
@ -1354,11 +1350,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey,
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell()
|
||||
signingKey
|
||||
)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
|
||||
71
app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt
Normal file
71
app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt
Normal file
@ -0,0 +1,71 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
/**
|
||||
* 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 bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?
|
||||
)
|
||||
|
||||
fun handleAuthenticatedRadioMessage(messageId: String)
|
||||
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
|
||||
fun handlePrekeyPacket(packet: BitchatPacket)
|
||||
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
|
||||
fun handleCourierEnvelope(payload: ByteArray)
|
||||
}
|
||||
|
||||
object BridgeMeshPort : BridgeMeshDelegate {
|
||||
@Volatile
|
||||
private var delegate: BridgeMeshDelegate? = null
|
||||
|
||||
fun install(delegate: BridgeMeshDelegate) {
|
||||
this.delegate = delegate
|
||||
}
|
||||
|
||||
override fun advertisedCell(): String? = delegate?.advertisedCell()
|
||||
|
||||
override fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?
|
||||
) {
|
||||
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override fun handleCourierEnvelope(payload: ByteArray) {
|
||||
delegate?.handleCourierEnvelope(payload)
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
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
|
||||
@ -527,7 +528,7 @@ class MeshCore(
|
||||
if (channel == null) {
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.bridgeOutgoing(
|
||||
BridgeMeshPort.bridgeOutgoing(
|
||||
content,
|
||||
myPeerID,
|
||||
packet.timestamp.toLong(),
|
||||
@ -572,16 +573,13 @@ class MeshCore(
|
||||
) {
|
||||
if (payload.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = if (payload.size > 0xFFFF) 2u else 1u,
|
||||
type = type.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = recipientPeerID?.let(MeshPacketUtils::hexStringToByteArray),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
val packet = BridgeProtocolPacketFactory.protocolPacket(
|
||||
type = type,
|
||||
payload = payload,
|
||||
signature = null,
|
||||
senderPeerId = myPeerID,
|
||||
recipientPeerId = recipientPeerID,
|
||||
ttl = maxTtl
|
||||
)
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign && outgoing.signature?.size != 64) return@launch
|
||||
dispatchGlobal(RoutedPacket(outgoing))
|
||||
@ -817,11 +815,10 @@ class MeshCore(
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey,
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell()
|
||||
signingKey
|
||||
)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
@ -843,11 +840,10 @@ class MeshCore(
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey,
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell()
|
||||
signingKey
|
||||
)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
|
||||
@ -319,7 +319,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
capabilities = announcement.capabilities
|
||||
) ?: false
|
||||
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.handleVerifiedAnnouncement(
|
||||
BridgeMeshPort.handleVerifiedAnnouncement(
|
||||
peerID,
|
||||
announcement
|
||||
)
|
||||
@ -465,8 +465,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
com.bitchat.android.services.bridge.MeshBridgeService
|
||||
.handleAuthenticatedRadioMessage(message.id)
|
||||
BridgeMeshPort.handleAuthenticatedRadioMessage(message.id)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
|
||||
}
|
||||
|
||||
@ -150,14 +150,14 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
MessageType.PREKEY_BUNDLE -> {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.handlePrekeyPacket(packet)
|
||||
BridgeMeshPort.handlePrekeyPacket(packet)
|
||||
}
|
||||
MessageType.NOSTR_CARRIER -> {
|
||||
val directedToUs = packetRelayManager.isPacketAddressedToMe(packet)
|
||||
val isBroadcast = packet.recipientID == null ||
|
||||
packet.recipientID.contentEquals(delegate?.getBroadcastRecipient())
|
||||
if (directedToUs || isBroadcast) {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.handleCarrier(
|
||||
BridgeMeshPort.handleCarrier(
|
||||
packet.payload,
|
||||
peerID,
|
||||
directedToUs
|
||||
@ -171,8 +171,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
|
||||
MessageType.COURIER_ENVELOPE -> {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService
|
||||
.handleCourierEnvelope(packet.payload)
|
||||
BridgeMeshPort.handleCourierEnvelope(packet.payload)
|
||||
}
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import javax.crypto.Mac
|
||||
@ -27,26 +26,28 @@ data class CourierEnvelope(
|
||||
if (recipientTag.size != TAG_LENGTH) return null
|
||||
if (ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES) return null
|
||||
|
||||
val output = ByteArrayOutputStream(ciphertext.size + 40)
|
||||
appendTlv(output, TLV_RECIPIENT_TAG, recipientTag)
|
||||
appendTlv(
|
||||
output,
|
||||
TLV_EXPIRY,
|
||||
ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(expiry).array()
|
||||
val fields = mutableListOf(
|
||||
Tlv16Codec.Field(TLV_RECIPIENT_TAG, recipientTag),
|
||||
Tlv16Codec.Field(
|
||||
TLV_EXPIRY,
|
||||
ByteBuffer.allocate(Long.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putLong(expiry)
|
||||
.array()
|
||||
),
|
||||
Tlv16Codec.Field(TLV_CIPHERTEXT, ciphertext)
|
||||
)
|
||||
appendTlv(output, TLV_CIPHERTEXT, ciphertext)
|
||||
if (normalizedCopies > 1) {
|
||||
appendTlv(output, TLV_COPIES, byteArrayOf(normalizedCopies.toByte()))
|
||||
fields += Tlv16Codec.Field(TLV_COPIES, byteArrayOf(normalizedCopies.toByte()))
|
||||
}
|
||||
prekeyId?.let {
|
||||
if (it !in 0..0xFFFF_FFFFL) return null
|
||||
appendTlv(
|
||||
output,
|
||||
fields += Tlv16Codec.Field(
|
||||
TLV_PREKEY_ID,
|
||||
ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array()
|
||||
)
|
||||
}
|
||||
return output.toByteArray()
|
||||
return Tlv16Codec.encode(*fields.toTypedArray())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@ -63,48 +64,39 @@ data class CourierEnvelope(
|
||||
private val TAG_CONTEXT = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun decode(data: ByteArray): CourierEnvelope? {
|
||||
var offset = 0
|
||||
var recipientTag: ByteArray? = null
|
||||
var expiry: Long? = null
|
||||
var ciphertext: ByteArray? = null
|
||||
var copies = 1
|
||||
var prekeyId: Long? = null
|
||||
|
||||
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
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
|
||||
when (type) {
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_RECIPIENT_TAG -> {
|
||||
if (length != TAG_LENGTH) return null
|
||||
recipientTag = value
|
||||
if (field.value.size != TAG_LENGTH) return null
|
||||
recipientTag = field.value
|
||||
}
|
||||
TLV_EXPIRY -> {
|
||||
if (length != Long.SIZE_BYTES) return null
|
||||
expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long
|
||||
if (field.value.size != Long.SIZE_BYTES) return null
|
||||
expiry = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long
|
||||
}
|
||||
TLV_CIPHERTEXT -> {
|
||||
if (length !in 1..MAX_CIPHERTEXT_BYTES) return null
|
||||
ciphertext = value
|
||||
if (field.value.size !in 1..MAX_CIPHERTEXT_BYTES) return null
|
||||
ciphertext = field.value
|
||||
}
|
||||
TLV_COPIES -> {
|
||||
if (length != 1) return null
|
||||
copies = value[0].toInt() and 0xFF
|
||||
if (field.value.size != 1) return null
|
||||
copies = field.value[0].toInt() and 0xFF
|
||||
}
|
||||
TLV_PREKEY_ID -> {
|
||||
if (length != Int.SIZE_BYTES) return null
|
||||
if (field.value.size != Int.SIZE_BYTES) return null
|
||||
prekeyId =
|
||||
ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xFFFF_FFFFL
|
||||
ByteBuffer.wrap(field.value)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.int.toLong() and 0xFFFF_FFFFL
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return CourierEnvelope(
|
||||
recipientTag = recipientTag ?: return null,
|
||||
@ -135,12 +127,6 @@ data class CourierEnvelope(
|
||||
.map { recipientTag(noiseStaticKey, it) }
|
||||
}
|
||||
|
||||
private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) {
|
||||
output.write(type)
|
||||
output.write((value.size ushr 8) and 0xFF)
|
||||
output.write(value.size and 0xFF)
|
||||
output.write(value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Wire payload for MessageType.NOSTR_CARRIER (0x28).
|
||||
@ -34,11 +33,13 @@ data class NostrCarrierPacket(
|
||||
NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8))
|
||||
|
||||
fun encode(): ByteArray {
|
||||
val output = ByteArrayOutputStream(eventJson.size + geohash.length + 12)
|
||||
appendTlv(output, TLV_DIRECTION, byteArrayOf(direction.value.toByte()))
|
||||
appendTlv(output, TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8))
|
||||
appendTlv(output, TLV_EVENT_JSON, eventJson)
|
||||
return output.toByteArray()
|
||||
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 {
|
||||
@ -59,34 +60,24 @@ data class NostrCarrierPacket(
|
||||
}.getOrNull()
|
||||
|
||||
fun decode(data: ByteArray): NostrCarrierPacket? {
|
||||
var offset = 0
|
||||
var direction: Direction? = null
|
||||
var geohash: String? = null
|
||||
var eventJson: ByteArray? = null
|
||||
|
||||
while (offset + 3 <= data.size) {
|
||||
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
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
|
||||
when (type) {
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_DIRECTION -> {
|
||||
if (value.size != 1) return null
|
||||
direction = Direction.fromValue(value[0].toInt() and 0xFF) ?: return null
|
||||
if (field.value.size != 1) return null
|
||||
direction =
|
||||
Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null
|
||||
}
|
||||
TLV_GEOHASH -> {
|
||||
geohash = value.toString(Charsets.UTF_8)
|
||||
geohash = field.value.toString(Charsets.UTF_8)
|
||||
}
|
||||
TLV_EVENT_JSON -> eventJson = value
|
||||
TLV_EVENT_JSON -> eventJson = field.value
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
if (offset != data.size) return null
|
||||
return runCatching {
|
||||
NostrCarrierPacket(
|
||||
direction = direction ?: return null,
|
||||
@ -96,12 +87,6 @@ data class NostrCarrierPacket(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) {
|
||||
output.write(type)
|
||||
output.write((value.size ushr 8) and 0xFF)
|
||||
output.write(value.size and 0xFF)
|
||||
output.write(value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
|
||||
@ -62,12 +62,12 @@ data class PrekeyBundle(
|
||||
entries.write(prekey.publicKey)
|
||||
}
|
||||
|
||||
return ByteArrayOutputStream(128 + entries.size()).apply {
|
||||
appendTlv(this, TLV_NOISE_STATIC_KEY, noiseStaticPublicKey)
|
||||
appendTlv(this, TLV_PREKEYS, entries.toByteArray())
|
||||
appendTlv(this, TLV_GENERATED_AT, uint64Bytes(generatedAt))
|
||||
appendTlv(this, TLV_SIGNATURE, signature)
|
||||
}.toByteArray()
|
||||
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 {
|
||||
@ -83,43 +83,33 @@ data class PrekeyBundle(
|
||||
private const val TLV_SIGNATURE = 0x04
|
||||
|
||||
fun decode(data: ByteArray): PrekeyBundle? {
|
||||
var offset = 0
|
||||
var noiseStaticKey: ByteArray? = null
|
||||
var prekeys: List<Prekey>? = null
|
||||
var generatedAt: Long? = null
|
||||
var signature: ByteArray? = null
|
||||
|
||||
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
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
|
||||
when (type) {
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_NOISE_STATIC_KEY -> {
|
||||
if (length != KEY_LENGTH) return null
|
||||
noiseStaticKey = value
|
||||
if (field.value.size != KEY_LENGTH) return null
|
||||
noiseStaticKey = field.value
|
||||
}
|
||||
TLV_PREKEYS -> {
|
||||
if (length == 0 ||
|
||||
length % PREKEY_ENTRY_LENGTH != 0 ||
|
||||
length / PREKEY_ENTRY_LENGTH > MAX_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<Prekey>()
|
||||
var entryOffset = 0
|
||||
while (entryOffset < value.size) {
|
||||
val id = ByteBuffer.wrap(value, entryOffset, Int.SIZE_BYTES)
|
||||
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 = value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH)
|
||||
val publicKey =
|
||||
field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH)
|
||||
entryOffset += KEY_LENGTH
|
||||
parsed += Prekey(id, publicKey)
|
||||
}
|
||||
@ -127,15 +117,15 @@ data class PrekeyBundle(
|
||||
prekeys = parsed
|
||||
}
|
||||
TLV_GENERATED_AT -> {
|
||||
if (length != Long.SIZE_BYTES) return null
|
||||
generatedAt = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long
|
||||
if (field.value.size != Long.SIZE_BYTES) return null
|
||||
generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long
|
||||
}
|
||||
TLV_SIGNATURE -> {
|
||||
if (length != SIGNATURE_LENGTH) return null
|
||||
signature = value
|
||||
if (field.value.size != SIGNATURE_LENGTH) return null
|
||||
signature = field.value
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return runCatching {
|
||||
PrekeyBundle(
|
||||
@ -147,13 +137,6 @@ data class PrekeyBundle(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) {
|
||||
output.write(type)
|
||||
output.write((value.size ushr 8) and 0xFF)
|
||||
output.write(value.size and 0xFF)
|
||||
output.write(value)
|
||||
}
|
||||
|
||||
private fun uint32Bytes(value: Long): ByteArray =
|
||||
ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
47
app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt
Normal file
47
app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt
Normal file
@ -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<Field>? {
|
||||
val fields = mutableListOf<Field>()
|
||||
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
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
sealed interface NostrPublishResult {
|
||||
data class Accepted(val relayUrl: String) : NostrPublishResult
|
||||
data class Rejected(val reasons: Map<String, String?>) : NostrPublishResult
|
||||
data object TimedOut : NostrPublishResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlates NIP-20 OK responses with callers that require relay acceptance.
|
||||
*
|
||||
* Most Nostr publishers remain fire-and-forget. Security-sensitive callers,
|
||||
* such as courier delivery, opt into this tracker so local dedup state is not
|
||||
* advanced before a relay has actually stored the event.
|
||||
*/
|
||||
internal class NostrPublishTracker {
|
||||
private data class Attempt(
|
||||
val remainingRelays: MutableSet<String>,
|
||||
val rejections: MutableMap<String, String?>,
|
||||
val result: CompletableDeferred<NostrPublishResult>
|
||||
)
|
||||
|
||||
private val attempts = ConcurrentHashMap<String, Attempt>()
|
||||
|
||||
fun begin(eventId: String, relayUrls: Set<String>): CompletableDeferred<NostrPublishResult> {
|
||||
val result = CompletableDeferred<NostrPublishResult>()
|
||||
if (relayUrls.isEmpty()) {
|
||||
result.complete(NostrPublishResult.Rejected(emptyMap()))
|
||||
return result
|
||||
}
|
||||
attempts.put(eventId, Attempt(relayUrls.toMutableSet(), mutableMapOf(), result))
|
||||
?.result
|
||||
?.cancel()
|
||||
return result
|
||||
}
|
||||
|
||||
fun record(
|
||||
eventId: String,
|
||||
relayUrl: String,
|
||||
accepted: Boolean,
|
||||
message: String?
|
||||
) {
|
||||
val attempt = attempts[eventId] ?: return
|
||||
synchronized(attempt) {
|
||||
if (attempt.result.isCompleted || relayUrl !in attempt.remainingRelays) return
|
||||
if (accepted) {
|
||||
attempt.result.complete(NostrPublishResult.Accepted(relayUrl))
|
||||
attempts.remove(eventId, attempt)
|
||||
return
|
||||
}
|
||||
attempt.remainingRelays.remove(relayUrl)
|
||||
attempt.rejections[relayUrl] = message
|
||||
if (attempt.remainingRelays.isEmpty()) {
|
||||
attempt.result.complete(NostrPublishResult.Rejected(attempt.rejections.toMap()))
|
||||
attempts.remove(eventId, attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(eventId: String, result: CompletableDeferred<NostrPublishResult>) {
|
||||
attempts.computeIfPresent(eventId) { _, attempt ->
|
||||
if (attempt.result === result) null else attempt
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -19,12 +19,20 @@ import kotlin.math.pow
|
||||
* Compatible with iOS implementation with Android-specific optimizations
|
||||
*/
|
||||
class NostrRelayManager private constructor() {
|
||||
private data class QueuedEvent(
|
||||
val event: NostrEvent,
|
||||
val pendingRelays: MutableSet<String>,
|
||||
val queuedAtMs: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val shared = NostrRelayManager()
|
||||
|
||||
private const val TAG = "NostrRelayManager"
|
||||
private const val PUBLISH_ACK_TIMEOUT_MS = 10_000L
|
||||
private const val MESSAGE_QUEUE_RETENTION_MS = 24L * 60 * 60 * 1000
|
||||
private const val MAX_MESSAGE_QUEUE_SIZE = 500
|
||||
|
||||
/**
|
||||
* Get instance for Android compatibility (context-aware calls)
|
||||
@ -104,8 +112,9 @@ class NostrRelayManager private constructor() {
|
||||
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
|
||||
|
||||
// Message queue for reliability
|
||||
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
|
||||
private val messageQueue = mutableListOf<QueuedEvent>()
|
||||
private val messageQueueLock = Any()
|
||||
private val publishTracker = NostrPublishTracker()
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@ -227,7 +236,7 @@ class NostrRelayManager private constructor() {
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||
_relays.value = relaysList.toList()
|
||||
_relays.value = relaysList.map { it.copy() }
|
||||
updateConnectionStatus()
|
||||
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
||||
} catch (e: Exception) {
|
||||
@ -280,11 +289,19 @@ class NostrRelayManager private constructor() {
|
||||
* Send an event to specified relays (or all if none specified)
|
||||
*/
|
||||
fun sendEvent(event: NostrEvent, relayUrls: List<String>? = null) {
|
||||
val targetRelays = relayUrls ?: relaysList.map { it.url }
|
||||
val targetRelays = (relayUrls ?: relaysList.map { it.url }).distinct()
|
||||
if (targetRelays.isEmpty()) return
|
||||
|
||||
// Add to queue for reliability
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.add(Pair(event, targetRelays))
|
||||
val now = System.currentTimeMillis()
|
||||
messageQueue.removeAll {
|
||||
now - it.queuedAtMs > MESSAGE_QUEUE_RETENTION_MS || it.event.id == event.id
|
||||
}
|
||||
messageQueue += QueuedEvent(event, targetRelays.toMutableSet(), now)
|
||||
while (messageQueue.size > MAX_MESSAGE_QUEUE_SIZE) {
|
||||
messageQueue.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt immediate send
|
||||
@ -297,6 +314,41 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish and wait until at least one target relay accepts the event.
|
||||
*
|
||||
* A timeout is not success: callers that persist delivery dedup state must
|
||||
* retain their own retryable payload until [NostrPublishResult.Accepted].
|
||||
*/
|
||||
suspend fun sendEventAndAwaitAcceptance(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>? = null,
|
||||
timeoutMs: Long = PUBLISH_ACK_TIMEOUT_MS
|
||||
): NostrPublishResult {
|
||||
val targets = (relayUrls ?: relaysList.map { it.url })
|
||||
.distinct()
|
||||
.mapNotNull { relayUrl ->
|
||||
connections[relayUrl]?.let { relayUrl to it }
|
||||
}
|
||||
if (targets.isEmpty()) return NostrPublishResult.Rejected(emptyMap())
|
||||
val result = publishTracker.begin(event.id, targets.mapTo(mutableSetOf()) { it.first })
|
||||
targets.forEach { (relayUrl, webSocket) ->
|
||||
if (!sendToRelay(event, webSocket, relayUrl)) {
|
||||
publishTracker.record(
|
||||
eventId = event.id,
|
||||
relayUrl = relayUrl,
|
||||
accepted = false,
|
||||
message = "WebSocket send failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
return try {
|
||||
withTimeoutOrNull(timeoutMs) { result.await() } ?: NostrPublishResult.TimedOut
|
||||
} finally {
|
||||
publishTracker.cancel(event.id, result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events matching a filter
|
||||
@ -629,8 +681,8 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) {
|
||||
try {
|
||||
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean {
|
||||
return try {
|
||||
val request = NostrRequest.Event(event)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
@ -645,8 +697,10 @@ class NostrRelayManager private constructor() {
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@ -711,6 +765,13 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
is NostrResponse.Ok -> {
|
||||
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
|
||||
publishTracker.record(
|
||||
eventId = response.eventId,
|
||||
relayUrl = relayUrl,
|
||||
accepted = response.accepted,
|
||||
message = response.message
|
||||
)
|
||||
acknowledgeQueuedEvent(response.eventId, relayUrl, response.accepted)
|
||||
if (response.accepted) {
|
||||
Log.d(TAG, "✅ Event accepted id=${response.eventId.take(16)}... by relay: $relayUrl")
|
||||
} else {
|
||||
@ -778,6 +839,22 @@ class NostrRelayManager private constructor() {
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acknowledgeQueuedEvent(eventId: String, relayUrl: String, accepted: Boolean) {
|
||||
synchronized(messageQueueLock) {
|
||||
val iterator = messageQueue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val queued = iterator.next()
|
||||
if (queued.event.id != eventId) continue
|
||||
if (accepted) {
|
||||
iterator.remove()
|
||||
} else {
|
||||
queued.pendingRelays.remove(relayUrl)
|
||||
if (queued.pendingRelays.isEmpty()) iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) {
|
||||
val relay = relaysList.find { it.url == url } ?: return
|
||||
@ -798,7 +875,7 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
|
||||
private fun updateRelaysList() {
|
||||
_relays.value = relaysList.toList()
|
||||
_relays.value = relaysList.map { it.copy() }
|
||||
}
|
||||
|
||||
private fun updateConnectionStatus() {
|
||||
@ -863,9 +940,9 @@ class NostrRelayManager private constructor() {
|
||||
synchronized(messageQueueLock) {
|
||||
val iterator = messageQueue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (event, targetRelays) = iterator.next()
|
||||
if (relayUrl in targetRelays) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
val queued = iterator.next()
|
||||
if (relayUrl in queued.pendingRelays) {
|
||||
sendToRelay(queued.event, webSocket, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -890,4 +967,5 @@ class NostrRelayManager private constructor() {
|
||||
handleDisconnection(relayUrl, t)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
object AppStateStore {
|
||||
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
private val publicMessageReconciler = PublicMessageReconciler()
|
||||
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
|
||||
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
@ -87,19 +87,14 @@ object AppStateStore {
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (!msg.isBridged) {
|
||||
val filtered = _publicMessages.value.filterNot {
|
||||
it.isBridged && it.bridgeRadioMessageIdHint == msg.id
|
||||
}
|
||||
if (filtered.size != _publicMessages.value.size) {
|
||||
_publicMessages.value = filtered
|
||||
}
|
||||
}
|
||||
val publicKey = publicMessageKey(msg)
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
val result = publicMessageReconciler.reconcile(
|
||||
existing = _publicMessages.value,
|
||||
incoming = msg,
|
||||
messageIdAlreadySeen = msg.id in seenMessageIds
|
||||
)
|
||||
_publicMessages.value = result.messages
|
||||
if (!result.accepted) return
|
||||
seenMessageIds.add(msg.id)
|
||||
seenPublicMessageKeys.add(publicKey)
|
||||
_publicMessages.value = _publicMessages.value + msg
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,7 +213,7 @@ object AppStateStore {
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
publicMessageReconciler.clear()
|
||||
peerIdsByTransport.clear()
|
||||
directPeerIdsByTransport.clear()
|
||||
_peers.value = emptyList()
|
||||
@ -228,14 +223,4 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
private fun publicMessageKey(msg: BitchatMessage): String {
|
||||
val sender = msg.senderPeerID ?: msg.sender
|
||||
return listOf(
|
||||
sender,
|
||||
msg.timestamp.time.toString(),
|
||||
msg.type.name,
|
||||
msg.channel ?: "",
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,27 @@ import com.bitchat.android.favorites.FavoriteControlMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
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.launch
|
||||
|
||||
/**
|
||||
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
|
||||
*/
|
||||
class MessageRouter private constructor(
|
||||
private val context: Context,
|
||||
private var mesh: MeshService,
|
||||
private val nostr: NostrTransport
|
||||
private val nostr: NostrTransport,
|
||||
private val currentNostrIdentity: () -> com.bitchat.android.nostr.NostrIdentity?
|
||||
) {
|
||||
private data class OutboxMessage(
|
||||
val content: String,
|
||||
val recipientNickname: String,
|
||||
val messageId: String
|
||||
)
|
||||
|
||||
enum class RouteResult {
|
||||
MESH,
|
||||
NOSTR,
|
||||
@ -29,8 +41,16 @@ class MessageRouter private constructor(
|
||||
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: run {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
val application = context.applicationContext
|
||||
val nostr = NostrTransport.getInstance(application)
|
||||
MessageRouter(
|
||||
mesh = mesh,
|
||||
nostr = nostr,
|
||||
currentNostrIdentity = {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge
|
||||
.getCurrentNostrIdentity(application)
|
||||
}
|
||||
).also { instance ->
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
@ -46,8 +66,8 @@ class MessageRouter private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Outbox: peerID -> queued (content, nickname, messageID)
|
||||
private val outbox = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val outbox = mutableMapOf<String, MutableList<OutboxMessage>>()
|
||||
|
||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||
@ -89,16 +109,22 @@ class MessageRouter private constructor(
|
||||
} 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(Triple(content, recipientNickname, messageID))
|
||||
q.add(OutboxMessage(content, recipientNickname, messageID))
|
||||
resolution.noisePublicKey?.let { recipientNoiseKey ->
|
||||
try {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.depositCourierDrop(
|
||||
content = content,
|
||||
messageId = messageID,
|
||||
recipientNoiseKey = recipientNoiseKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Courier deposit failed: ${e.message}")
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
|
||||
@ -126,7 +152,15 @@ class MessageRouter private constructor(
|
||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
|
||||
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
|
||||
if (recipientHex != null) {
|
||||
nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return })
|
||||
nostr.sendDeliveryAckGeohash(
|
||||
messageID,
|
||||
recipientHex,
|
||||
try {
|
||||
currentNostrIdentity() ?: return
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -141,7 +175,11 @@ class MessageRouter private constructor(
|
||||
val resolution = ContactDirectory.resolve(toPeerID)
|
||||
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
|
||||
if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) {
|
||||
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
|
||||
val myNpub = try {
|
||||
currentNostrIdentity()?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
|
||||
val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget
|
||||
mesh.sendPrivateMessage(content, meshTarget, nickname, null)
|
||||
@ -158,15 +196,25 @@ class MessageRouter private constructor(
|
||||
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (content, nickname, messageID) = iterator.next()
|
||||
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(content, meshTarget, nickname, messageID)
|
||||
mesh.sendPrivateMessage(
|
||||
queuedMessage.content,
|
||||
meshTarget,
|
||||
queuedMessage.recipientNickname,
|
||||
queuedMessage.messageId
|
||||
)
|
||||
iterator.remove()
|
||||
} else if (canSendViaNostr(nostrTarget)) {
|
||||
nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
|
||||
nostr.sendPrivateMessage(
|
||||
queuedMessage.content,
|
||||
nostrTarget,
|
||||
queuedMessage.recipientNickname,
|
||||
queuedMessage.messageId
|
||||
)
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Owns public-timeline replay deduplication and bridge/radio reconciliation.
|
||||
*
|
||||
* The store remains responsible for synchronization and cross-timeline IDs;
|
||||
* this class keeps bridge-specific alias policy independently testable.
|
||||
*/
|
||||
internal class PublicMessageReconciler {
|
||||
data class Result(
|
||||
val messages: List<BitchatMessage>,
|
||||
val accepted: Boolean
|
||||
)
|
||||
|
||||
private val seenKeys = mutableSetOf<String>()
|
||||
|
||||
fun reconcile(
|
||||
existing: List<BitchatMessage>,
|
||||
incoming: BitchatMessage,
|
||||
messageIdAlreadySeen: Boolean
|
||||
): Result {
|
||||
val withoutBridgeAliases = if (incoming.isBridged) {
|
||||
existing
|
||||
} else {
|
||||
existing.filterNot {
|
||||
it.isBridged && it.bridgeRadioMessageIdHint == incoming.id
|
||||
}
|
||||
}
|
||||
val key = publicMessageKey(incoming)
|
||||
if (messageIdAlreadySeen || key in seenKeys) {
|
||||
return Result(withoutBridgeAliases, accepted = false)
|
||||
}
|
||||
seenKeys += key
|
||||
return Result(withoutBridgeAliases + incoming, accepted = true)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
seenKeys.clear()
|
||||
}
|
||||
|
||||
private fun publicMessageKey(message: BitchatMessage): String {
|
||||
val sender = message.senderPeerID ?: message.sender
|
||||
return listOf(
|
||||
sender,
|
||||
message.timestamp.time.toString(),
|
||||
message.type.name,
|
||||
message.channel ?: "",
|
||||
message.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
internal class BoundedIdSet(private val capacity: Int) {
|
||||
private val values = LinkedHashSet<String>()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* A small insertion-ordered expiring set. Callers own synchronization; bridge
|
||||
* coordinators keep each instance confined to their serial dispatcher.
|
||||
*/
|
||||
internal class PersistentExpiringIdSet(
|
||||
private val preferences: SharedPreferences,
|
||||
private val key: String,
|
||||
private val capacity: Int
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val values: LinkedHashMap<String, Long> = load()
|
||||
|
||||
fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
prune(nowMs)
|
||||
return (values[id] ?: return false) > nowMs
|
||||
}
|
||||
|
||||
fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) {
|
||||
prune(nowMs)
|
||||
values.remove(id)
|
||||
values[id] = nowMs + lifetimeMs
|
||||
while (values.size > capacity) values.remove(values.keys.first())
|
||||
persist()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
values.clear()
|
||||
preferences.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun prune(nowMs: Long) {
|
||||
val changed = values.entries.removeAll { it.value <= nowMs }
|
||||
if (changed) persist()
|
||||
}
|
||||
|
||||
private fun load(): LinkedHashMap<String, Long> {
|
||||
val type = object : TypeToken<Map<String, Long>>() {}.type
|
||||
val decoded: Map<String, Long> = runCatching {
|
||||
preferences.getString(key, null)
|
||||
?.let { json -> gson.fromJson<Map<String, Long>>(json, type) }
|
||||
}.getOrNull() ?: emptyMap()
|
||||
return LinkedHashMap(decoded)
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
preferences.edit { putString(key, gson.toJson(values)) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
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<BridgedParticipant> = emptyList()
|
||||
)
|
||||
|
||||
sealed interface CourierDepositResult {
|
||||
data object Published : CourierDepositResult
|
||||
data object ForwardedToGateway : CourierDepositResult
|
||||
data object QueuedLocally : CourierDepositResult
|
||||
data object AlreadyPublished : CourierDepositResult
|
||||
data class Rejected(val reason: Reason) : CourierDepositResult
|
||||
|
||||
enum class Reason {
|
||||
BRIDGE_DISABLED,
|
||||
CONTENT_TOO_LARGE,
|
||||
INVALID_MESSAGE,
|
||||
ENCRYPTION_FAILED
|
||||
}
|
||||
}
|
||||
|
||||
internal data class VerifiedBridgePeer(
|
||||
val peerId: String,
|
||||
val nickname: String,
|
||||
val noiseKey: ByteArray,
|
||||
val signingKey: ByteArray,
|
||||
val capabilities: PeerCapabilities?,
|
||||
val bridgeCell: String?,
|
||||
val lastSeenMs: Long
|
||||
)
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,424 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
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 com.bitchat.android.nostr.NostrEvent
|
||||
import com.bitchat.android.nostr.NostrIdentity
|
||||
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.services.AppStateStore
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.security.MessageDigest
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Store-and-forward courier state machine.
|
||||
*
|
||||
* All mutable state and persistent dedup access are confined to [dispatcher].
|
||||
* The bridge facade only supplies immutable peer snapshots and transport
|
||||
* dependencies.
|
||||
*/
|
||||
internal class CourierCoordinator(
|
||||
context: Context,
|
||||
preferences: SharedPreferences,
|
||||
private val relayManager: NostrRelayManager,
|
||||
private val prekeys: PrekeyManager,
|
||||
private val meshProvider: () -> MeshService?,
|
||||
private val peersProvider: () -> List<VerifiedBridgePeer>,
|
||||
private val onPrekeyConsumed: () -> 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
|
||||
)
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val pendingDrops = mutableListOf<PendingDrop>()
|
||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||
private var subscribedTags: Set<String> = emptySet()
|
||||
@Volatile
|
||||
private var enabled = false
|
||||
private val publishedDropKeys =
|
||||
PersistentExpiringIdSet(preferences, "published_drop_keys", MAX_TRACKED_IDS)
|
||||
private val seenDropEventIds =
|
||||
PersistentExpiringIdSet(preferences, "seen_drop_events", MAX_TRACKED_IDS)
|
||||
private val openedMessageIds =
|
||||
PersistentExpiringIdSet(preferences, "opened_courier_messages", MAX_TRACKED_IDS)
|
||||
|
||||
fun setEnabled(value: Boolean) {
|
||||
// Privacy policy changes take effect before queued coordinator work.
|
||||
enabled = value
|
||||
scope.launch {
|
||||
if (value) {
|
||||
refreshSubscription()
|
||||
} else {
|
||||
closeSubscription()
|
||||
pendingDrops.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peerStateChanged() {
|
||||
scope.launch {
|
||||
if (enabled) refreshSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
fun relayConnected() {
|
||||
scope.launch {
|
||||
if (!enabled) return@launch
|
||||
refreshSubscription()
|
||||
flushPendingDrops()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEnvelope(payload: ByteArray) {
|
||||
scope.launch {
|
||||
val envelope = CourierEnvelope.decode(payload) ?: return@launch
|
||||
if (!validLifetime(envelope)) return@launch
|
||||
if (isMyTag(envelope.recipientTag)) {
|
||||
openEnvelope(envelope)
|
||||
} else if (enabled) {
|
||||
publishOrQueue(envelope, dedupKey = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deposit(
|
||||
content: String,
|
||||
messageId: String,
|
||||
recipientNoiseKey: ByteArray
|
||||
): CourierDepositResult = withContext(dispatcher) {
|
||||
if (!enabled) {
|
||||
return@withContext CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.BRIDGE_DISABLED
|
||||
)
|
||||
}
|
||||
if (content.toByteArray(Charsets.UTF_8).size > MAX_PRIVATE_MESSAGE_BYTES) {
|
||||
return@withContext CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.CONTENT_TOO_LARGE
|
||||
)
|
||||
}
|
||||
val now = clock()
|
||||
val dedupKey = senderDropKey(messageId, recipientNoiseKey)
|
||||
if (publishedDropKeys.contains(dedupKey, now)) {
|
||||
return@withContext CourierDepositResult.AlreadyPublished
|
||||
}
|
||||
if (pendingDrops.any { it.queueKey == dedupKey }) {
|
||||
return@withContext CourierDepositResult.QueuedLocally
|
||||
}
|
||||
val privatePacket = PrivateMessagePacket(messageId, content).encode()
|
||||
?: return@withContext CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.INVALID_MESSAGE
|
||||
)
|
||||
val typedPayload = NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privatePacket).encode()
|
||||
val livePeer = peersProvider().firstOrNull {
|
||||
it.noiseKey.contentEquals(recipientNoiseKey) &&
|
||||
meshProvider()?.getPeerInfo(it.peerId)?.isConnected == true
|
||||
}
|
||||
val allowsPrekeys = livePeer?.capabilities?.contains(
|
||||
com.bitchat.android.model.PeerCapabilities.PREKEYS
|
||||
) != false
|
||||
val sealed = runCatching {
|
||||
prekeys.seal(
|
||||
typedPayload,
|
||||
messageId,
|
||||
recipientNoiseKey,
|
||||
recipientAdvertisesPrekeys = allowsPrekeys,
|
||||
nowMs = clock()
|
||||
)
|
||||
}.getOrNull() ?: return@withContext CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.ENCRYPTION_FAILED
|
||||
)
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(
|
||||
recipientNoiseKey,
|
||||
CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = now + CourierEnvelope.MAX_LIFETIME_MS,
|
||||
ciphertext = sealed.ciphertext,
|
||||
copies = 1,
|
||||
prekeyId = sealed.prekeyId
|
||||
)
|
||||
val encoded = envelope.encode()
|
||||
?: return@withContext CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.INVALID_MESSAGE
|
||||
)
|
||||
if (encoded.size > MAX_DROP_BYTES) {
|
||||
return@withContext CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.CONTENT_TOO_LARGE
|
||||
)
|
||||
}
|
||||
if (relayManager.isConnected.value) {
|
||||
return@withContext publishOrQueue(envelope, dedupKey)
|
||||
}
|
||||
val gateway = availableGateway()
|
||||
if (gateway != null) {
|
||||
meshProvider()?.sendCourierEnvelope(encoded, gateway.peerId)
|
||||
return@withContext CourierDepositResult.ForwardedToGateway
|
||||
}
|
||||
enqueue(PendingDrop(envelope, dedupKey, dedupKey))
|
||||
CourierDepositResult.QueuedLocally
|
||||
}
|
||||
|
||||
suspend fun wipe() = withContext(dispatcher) {
|
||||
closeSubscription()
|
||||
pendingDrops.clear()
|
||||
signatureAttemptTimes.clear()
|
||||
publishedDropKeys.clear()
|
||||
seenDropEventIds.clear()
|
||||
openedMessageIds.clear()
|
||||
}
|
||||
|
||||
private fun refreshSubscription() {
|
||||
if (!enabled) return
|
||||
val now = clock()
|
||||
val identityKey =
|
||||
SecureIdentityStateManager(appContext).loadStaticKey()?.second ?: return
|
||||
val myTags = CourierEnvelope.candidateTags(identityKey, now).map { it.toHex() }.toSet()
|
||||
val peerTags = peersProvider()
|
||||
.asSequence()
|
||||
.filter { meshProvider()?.getPeerInfo(it.peerId)?.isConnected == true }
|
||||
.take(MAX_WATCHED_PEERS)
|
||||
.flatMap { peer ->
|
||||
CourierEnvelope.candidateTags(peer.noiseKey, now)
|
||||
.asSequence()
|
||||
.map { bytes -> bytes.toHex() }
|
||||
}
|
||||
.toSet()
|
||||
val allTags = myTags + peerTags
|
||||
if (allTags == subscribedTags) return
|
||||
relayManager.unsubscribe(COURIER_SUBSCRIPTION)
|
||||
subscribedTags = allTags
|
||||
if (allTags.isEmpty()) return
|
||||
relayManager.subscribe(
|
||||
filter = com.bitchat.android.nostr.NostrFilter.courierDrops(
|
||||
allTags,
|
||||
since = now - CourierEnvelope.MAX_LIFETIME_MS
|
||||
),
|
||||
id = COURIER_SUBSCRIPTION,
|
||||
handler = { event -> scope.launch { handleDropEvent(event) } },
|
||||
targetRelayUrls = NostrRelayManager.defaultRelays()
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDropEvent(event: NostrEvent) {
|
||||
if (!enabled ||
|
||||
event.kind != NostrKind.COURIER_DROP ||
|
||||
seenDropEventIds.contains(event.id, clock()) ||
|
||||
!allowSignatureAttempt() ||
|
||||
!event.isValidSignature()
|
||||
) {
|
||||
return
|
||||
}
|
||||
val data = runCatching { Base64.decode(event.content, Base64.DEFAULT) }.getOrNull() ?: return
|
||||
if (data.size > MAX_DROP_BYTES) return
|
||||
val envelope = CourierEnvelope.decode(data) ?: return
|
||||
if (!validLifetime(envelope)) return
|
||||
val tagHex = envelope.recipientTag.toHex()
|
||||
if (event.tags.none { it.size >= 2 && it[0] == "x" && it[1] == tagHex }) return
|
||||
|
||||
if (isMyTag(envelope.recipientTag)) {
|
||||
if (openEnvelope(envelope)) {
|
||||
seenDropEventIds.add(event.id, DROP_DEDUP_MS, clock())
|
||||
}
|
||||
return
|
||||
}
|
||||
val peer = peersProvider()
|
||||
.asSequence()
|
||||
.filter { meshProvider()?.getPeerInfo(it.peerId)?.isConnected == true }
|
||||
.take(MAX_WATCHED_PEERS)
|
||||
.firstOrNull {
|
||||
CourierEnvelope.candidateTags(it.noiseKey, clock())
|
||||
.any { candidate -> candidate.contentEquals(envelope.recipientTag) }
|
||||
}
|
||||
if (peer != null) {
|
||||
meshProvider()?.sendCourierEnvelope(data, peer.peerId)
|
||||
seenDropEventIds.add(event.id, DROP_DEDUP_MS, clock())
|
||||
}
|
||||
}
|
||||
|
||||
private fun openEnvelope(envelope: CourierEnvelope): Boolean {
|
||||
val opened = runCatching {
|
||||
prekeys.open(envelope.ciphertext, envelope.prekeyId, clock())
|
||||
}.getOrNull() ?: return false
|
||||
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
|
||||
val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey)
|
||||
val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex())
|
||||
val message = BitchatMessage(
|
||||
id = privateMessage.messageID,
|
||||
sender = senderResolution.displayName
|
||||
?: peersProvider().firstOrNull { it.peerId == senderPeerId }?.nickname
|
||||
?: "Unknown",
|
||||
content = privateMessage.content,
|
||||
timestamp = Date(clock()),
|
||||
isPrivate = true,
|
||||
recipientNickname = meshProvider()?.myPeerID,
|
||||
senderPeerID = senderPeerId
|
||||
)
|
||||
AppStateStore.addPrivateMessage(
|
||||
ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey),
|
||||
message
|
||||
)
|
||||
openedMessageIds.add(privateMessage.messageID, DROP_DEDUP_MS, clock())
|
||||
if (opened.consumedPrekey) onPrekeyConsumed()
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun publishOrQueue(
|
||||
envelope: CourierEnvelope,
|
||||
dedupKey: String?
|
||||
): CourierDepositResult {
|
||||
if (!validLifetime(envelope)) {
|
||||
return CourierDepositResult.Rejected(CourierDepositResult.Reason.INVALID_MESSAGE)
|
||||
}
|
||||
if (!relayManager.isConnected.value) {
|
||||
enqueue(
|
||||
PendingDrop(
|
||||
envelope,
|
||||
dedupKey,
|
||||
dedupKey ?: envelopeQueueKey(envelope)
|
||||
)
|
||||
)
|
||||
return CourierDepositResult.QueuedLocally
|
||||
}
|
||||
val encoded = envelope.encode()
|
||||
?: return CourierDepositResult.Rejected(CourierDepositResult.Reason.INVALID_MESSAGE)
|
||||
if (encoded.size > MAX_DROP_BYTES) {
|
||||
return CourierDepositResult.Rejected(CourierDepositResult.Reason.CONTENT_TOO_LARGE)
|
||||
}
|
||||
val event = NostrProtocol.createCourierDropEvent(
|
||||
envelope = encoded,
|
||||
recipientTagHex = envelope.recipientTag.toHex(),
|
||||
expiresAtMs = envelope.expiry,
|
||||
senderIdentity = NostrIdentity.generate()
|
||||
)
|
||||
return when (
|
||||
relayManager.sendEventAndAwaitAcceptance(
|
||||
event,
|
||||
NostrRelayManager.defaultRelays()
|
||||
)
|
||||
) {
|
||||
is NostrPublishResult.Accepted -> {
|
||||
if (!enabled) {
|
||||
return CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.BRIDGE_DISABLED
|
||||
)
|
||||
}
|
||||
dedupKey?.let { publishedDropKeys.add(it, DROP_DEDUP_MS, clock()) }
|
||||
CourierDepositResult.Published
|
||||
}
|
||||
is NostrPublishResult.Rejected,
|
||||
NostrPublishResult.TimedOut -> {
|
||||
if (!enabled) {
|
||||
return CourierDepositResult.Rejected(
|
||||
CourierDepositResult.Reason.BRIDGE_DISABLED
|
||||
)
|
||||
}
|
||||
enqueue(
|
||||
PendingDrop(
|
||||
envelope,
|
||||
dedupKey,
|
||||
dedupKey ?: envelopeQueueKey(envelope)
|
||||
)
|
||||
)
|
||||
CourierDepositResult.QueuedLocally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueue(drop: PendingDrop) {
|
||||
if (pendingDrops.any { it.queueKey == drop.queueKey }) return
|
||||
pendingDrops += drop
|
||||
while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0)
|
||||
}
|
||||
|
||||
private suspend fun flushPendingDrops() {
|
||||
if (!enabled || !relayManager.isConnected.value) return
|
||||
val queued = pendingDrops.toList()
|
||||
pendingDrops.clear()
|
||||
queued.forEach { publishOrQueue(it.envelope, it.dedupKey) }
|
||||
}
|
||||
|
||||
private fun closeSubscription() {
|
||||
relayManager.unsubscribe(COURIER_SUBSCRIPTION)
|
||||
subscribedTags = emptySet()
|
||||
}
|
||||
|
||||
private fun availableGateway(): VerifiedBridgePeer? =
|
||||
peersProvider().firstOrNull { peer ->
|
||||
peer.capabilities?.contains(com.bitchat.android.model.PeerCapabilities.BRIDGE) == true &&
|
||||
peer.bridgeCell != null &&
|
||||
meshProvider()?.getPeerInfo(peer.peerId)?.isConnected == true
|
||||
}
|
||||
|
||||
private fun isMyTag(tag: ByteArray): Boolean {
|
||||
val ownKey = SecureIdentityStateManager(appContext).loadStaticKey()?.second ?: return false
|
||||
return CourierEnvelope.candidateTags(ownKey, clock()).any { it.contentEquals(tag) }
|
||||
}
|
||||
|
||||
private fun validLifetime(envelope: CourierEnvelope): Boolean {
|
||||
val now = clock()
|
||||
return !envelope.isExpired(now) &&
|
||||
envelope.expiry > 0 &&
|
||||
envelope.expiry - now <= CourierEnvelope.MAX_LIFETIME_MS
|
||||
}
|
||||
|
||||
private fun senderDropKey(messageId: String, recipientNoiseKey: ByteArray): String {
|
||||
val material = recipientNoiseKey.toHex() + "|" + messageId
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(material.toByteArray(Charsets.UTF_8))
|
||||
.toHex()
|
||||
}
|
||||
|
||||
private fun envelopeQueueKey(envelope: CourierEnvelope): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(envelope.recipientTag + envelope.ciphertext)
|
||||
.toHex()
|
||||
|
||||
private fun allowSignatureAttempt(): Boolean {
|
||||
val now = clock()
|
||||
signatureAttemptTimes.removeAll { now - it >= RATE_WINDOW_MS }
|
||||
if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false
|
||||
signatureAttemptTimes += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private companion object {
|
||||
const val COURIER_SUBSCRIPTION = "mesh-bridge-courier"
|
||||
const val MAX_TRACKED_IDS = 512
|
||||
const val MAX_WATCHED_PEERS = 16
|
||||
const val MAX_PENDING_DROPS = 20
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,26 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.BridgeMeshDelegate
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.NostrCarrierPacket
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.nostr.MeshMessageIdentity
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
import com.bitchat.android.nostr.NostrFilter
|
||||
import com.bitchat.android.nostr.NostrIdentity
|
||||
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.service.MeshServiceHolder
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@ -41,13 +29,12 @@ 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 org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.MessageDigest
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Opt-in bridge policy shared by foreground transport and Compose UI.
|
||||
@ -56,26 +43,7 @@ import kotlin.random.Random
|
||||
* 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 {
|
||||
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)}"
|
||||
}
|
||||
|
||||
private data class VerifiedPeer(
|
||||
val peerId: String,
|
||||
val nickname: String,
|
||||
val noiseKey: ByteArray,
|
||||
val signingKey: ByteArray,
|
||||
val capabilities: PeerCapabilities?,
|
||||
val bridgeCell: String?,
|
||||
val lastSeenMs: Long
|
||||
)
|
||||
|
||||
object MeshBridgeService : BridgeMeshDelegate {
|
||||
private data class PendingUplink(
|
||||
val depositor: String,
|
||||
val cell: String,
|
||||
@ -87,16 +55,10 @@ object MeshBridgeService {
|
||||
val event: NostrEvent
|
||||
)
|
||||
|
||||
private data class PendingDrop(
|
||||
val envelope: CourierEnvelope,
|
||||
val dedupKey: String?
|
||||
)
|
||||
|
||||
private const val TAG = "MeshBridgeService"
|
||||
private const val PREFS = "bitchat_bridge"
|
||||
private const val KEY_ENABLED = "bridge_enabled_v1"
|
||||
private const val BRIDGE_SUBSCRIPTION = "mesh-bridge-rendezvous"
|
||||
private const val COURIER_SUBSCRIPTION = "mesh-bridge-courier"
|
||||
private const val CELL_PRECISION = 6
|
||||
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
|
||||
private const val MAX_CONTENT_BYTES = 16_000
|
||||
@ -112,13 +74,9 @@ object MeshBridgeService {
|
||||
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 const val MAX_WATCHED_COURIER_PEERS = 16
|
||||
private const val MAX_PENDING_DROPS = 20
|
||||
private const val MAX_DROP_BYTES = 20 * 1024
|
||||
private const val PREKEY_REBROADCAST_MS = 60L * 60 * 1000
|
||||
private const val DROP_DEDUP_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1))
|
||||
private val dispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val _isEnabled = MutableStateFlow(false)
|
||||
val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow()
|
||||
private val _nearbyOnly = MutableStateFlow(false)
|
||||
@ -131,13 +89,17 @@ object MeshBridgeService {
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
private var relayManager: NostrRelayManager? = null
|
||||
private var prekeys: PrekeyManager? = null
|
||||
private var prekeyCoordinator: PrekeyCoordinator? = null
|
||||
private var courierCoordinator: CourierCoordinator? = 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<String> = emptySet()
|
||||
private var subscribedCourierTags: Set<String> = emptySet()
|
||||
private val verifiedPeers = linkedMapOf<String, VerifiedPeer>()
|
||||
private val pendingPrekeyPackets = linkedMapOf<String, BitchatPacket>()
|
||||
private val verifiedPeers = linkedMapOf<String, VerifiedBridgePeer>()
|
||||
private val verifiedPeerSnapshot = AtomicReference<List<VerifiedBridgePeer>>(emptyList())
|
||||
private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
@ -146,7 +108,6 @@ object MeshBridgeService {
|
||||
private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val queuedUplinks = mutableListOf<PendingUplink>()
|
||||
private val pendingDownlinks = mutableListOf<PendingDownlink>()
|
||||
private val pendingDrops = mutableListOf<PendingDrop>()
|
||||
private val participants = linkedMapOf<String, BridgedParticipant>()
|
||||
private val uplinkTimes = mutableMapOf<String, MutableList<Long>>()
|
||||
private val inboundTimes = mutableListOf<Long>()
|
||||
@ -155,26 +116,47 @@ object MeshBridgeService {
|
||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||
private var downlinkJob: Job? = null
|
||||
private var presenceJob: Job? = null
|
||||
private var lastPrekeyBroadcastMs = 0L
|
||||
private var publishedDropKeys: PersistentExpiringIdSet? = null
|
||||
private var seenDropEventIds: PersistentExpiringIdSet? = null
|
||||
private var openedCourierMessageIds: PersistentExpiringIdSet? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
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)
|
||||
prekeys = PrekeyManager.getInstance(application)
|
||||
val prekeyManager = PrekeyManager.getInstance(application)
|
||||
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
_isEnabled.value = loadEnabledWithMigration(prefs!!)
|
||||
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
|
||||
publishedDropKeys = PersistentExpiringIdSet(prefs!!, "published_drop_keys", MAX_TRACKED_IDS)
|
||||
seenDropEventIds = PersistentExpiringIdSet(prefs!!, "seen_drop_events", MAX_TRACKED_IDS)
|
||||
openedCourierMessageIds =
|
||||
PersistentExpiringIdSet(prefs!!, "opened_courier_messages", MAX_TRACKED_IDS)
|
||||
prekeyCoordinator = PrekeyCoordinator(
|
||||
manager = prekeyManager,
|
||||
meshProvider = ::currentMesh,
|
||||
peersProvider = verifiedPeerSnapshot::get,
|
||||
clock = clock
|
||||
)
|
||||
courierCoordinator = CourierCoordinator(
|
||||
context = application,
|
||||
preferences = checkNotNull(prefs),
|
||||
relayManager = checkNotNull(relayManager),
|
||||
prekeys = prekeyManager,
|
||||
meshProvider = ::currentMesh,
|
||||
peersProvider = verifiedPeerSnapshot::get,
|
||||
onPrekeyConsumed = {
|
||||
scope.launch { prekeyCoordinator?.broadcast(force = true) }
|
||||
},
|
||||
clock = clock
|
||||
)
|
||||
courierCoordinator?.setEnabled(_isEnabled.value)
|
||||
}
|
||||
|
||||
val location = LocationChannelManager.getInstance(context)
|
||||
@ -192,21 +174,34 @@ object MeshBridgeService {
|
||||
if (connected) {
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
flushQueuedUplinks()
|
||||
flushPendingDrops()
|
||||
publishPresence()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
val defaultRelays = NostrRelayManager.defaultRelays().toSet()
|
||||
relayManager?.relays
|
||||
?.map { relays ->
|
||||
relays.asSequence()
|
||||
.filter { it.isConnected && it.url in defaultRelays }
|
||||
.map { it.url }
|
||||
.toSet()
|
||||
}
|
||||
?.distinctUntilChanged()
|
||||
?.collect { connectedDefaults ->
|
||||
if (connectedDefaults.isNotEmpty()) courierCoordinator?.relayConnected()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
if (_isEnabled.value) {
|
||||
relayManager?.connect()
|
||||
location.refreshChannels()
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
refreshCourierSubscription()
|
||||
courierCoordinator?.peerStateChanged()
|
||||
}
|
||||
startPresenceLoop()
|
||||
delay(2_000)
|
||||
broadcastPrekeyBundle(force = true)
|
||||
prekeyCoordinator?.broadcast(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,12 +211,12 @@ object MeshBridgeService {
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||
PeerCapabilities.setBridgeEnabled(enabled)
|
||||
_nearbyOnly.value = false
|
||||
courierCoordinator?.setEnabled(enabled)
|
||||
scope.launch {
|
||||
if (!enabled) {
|
||||
closeSubscriptions()
|
||||
queuedUplinks.clear()
|
||||
pendingDownlinks.clear()
|
||||
pendingDrops.clear()
|
||||
participants.clear()
|
||||
publishParticipants()
|
||||
_activeCell.value = null
|
||||
@ -229,8 +224,8 @@ object MeshBridgeService {
|
||||
relayManager?.connect()
|
||||
LocationChannelManager.getInstance(requireContext()).refreshChannels()
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
refreshCourierSubscription()
|
||||
broadcastPrekeyBundle(force = true)
|
||||
courierCoordinator?.peerStateChanged()
|
||||
prekeyCoordinator?.broadcast(force = true)
|
||||
}
|
||||
currentMesh()?.sendBroadcastAnnounce()
|
||||
}
|
||||
@ -241,9 +236,9 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
|
||||
fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
|
||||
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
|
||||
|
||||
fun bridgeOutgoing(
|
||||
override fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
@ -278,7 +273,7 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
/** Called only after a public radio packet's Ed25519 signature was accepted. */
|
||||
fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
if (messageId.isBlank()) return
|
||||
scope.launch {
|
||||
radioMessageIds.add(messageId)
|
||||
@ -288,33 +283,34 @@ object MeshBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
|
||||
override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
|
||||
scope.launch {
|
||||
val peer = VerifiedPeer(
|
||||
val peer = VerifiedBridgePeer(
|
||||
peerId = peerId,
|
||||
nickname = announcement.nickname,
|
||||
noiseKey = announcement.noisePublicKey.copyOf(),
|
||||
signingKey = announcement.signingPublicKey.copyOf(),
|
||||
capabilities = announcement.capabilities,
|
||||
bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash),
|
||||
lastSeenMs = System.currentTimeMillis()
|
||||
lastSeenMs = clock()
|
||||
)
|
||||
verifiedPeers[peerId] = peer
|
||||
while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first())
|
||||
pendingPrekeyPackets.remove(peerId)?.let { ingestPrekeyPacket(it) }
|
||||
publishVerifiedPeerSnapshot()
|
||||
prekeyCoordinator?.handlePeerVerified(peerId)
|
||||
if (_isEnabled.value) {
|
||||
refreshRendezvous()
|
||||
refreshCourierSubscription()
|
||||
courierCoordinator?.peerStateChanged()
|
||||
}
|
||||
broadcastPrekeyBundle()
|
||||
prekeyCoordinator?.broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePrekeyPacket(packet: BitchatPacket) {
|
||||
scope.launch { ingestPrekeyPacket(packet) }
|
||||
override fun handlePrekeyPacket(packet: BitchatPacket) {
|
||||
scope.launch { prekeyCoordinator?.handlePacket(packet) }
|
||||
}
|
||||
|
||||
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
|
||||
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
|
||||
scope.launch {
|
||||
val carrier = NostrCarrierPacket.decode(payload) ?: return@launch
|
||||
when (carrier.direction) {
|
||||
@ -330,87 +326,26 @@ object MeshBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCourierEnvelope(payload: ByteArray) {
|
||||
scope.launch {
|
||||
val envelope = CourierEnvelope.decode(payload) ?: return@launch
|
||||
if (!validEnvelopeLifetime(envelope)) return@launch
|
||||
if (isMyCourierTag(envelope.recipientTag)) {
|
||||
openCourierEnvelope(envelope)
|
||||
} else if (_isEnabled.value) {
|
||||
publishOrQueueDrop(envelope, dedupKey = null)
|
||||
}
|
||||
}
|
||||
override fun handleCourierEnvelope(payload: ByteArray) {
|
||||
courierCoordinator?.handleEnvelope(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit an offline DM either directly to relays or through a reachable
|
||||
* bridge peer. Returns true when a compatible envelope was produced and
|
||||
* accepted by one of those paths.
|
||||
*/
|
||||
fun depositCourierDrop(
|
||||
suspend fun depositCourierDrop(
|
||||
content: String,
|
||||
messageId: String,
|
||||
recipientNoiseKey: ByteArray
|
||||
): Boolean {
|
||||
if (!_isEnabled.value || content.toByteArray(Charsets.UTF_8).size > 255) return false
|
||||
val privatePacket = PrivateMessagePacket(messageId, content).encode() ?: return false
|
||||
val typedPayload = NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privatePacket).encode()
|
||||
val livePeer = verifiedPeers.values.firstOrNull {
|
||||
it.noiseKey.contentEquals(recipientNoiseKey) &&
|
||||
currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true
|
||||
}
|
||||
val allowsPrekeys = livePeer?.capabilities?.contains(PeerCapabilities.PREKEYS) != false
|
||||
val sealed = runCatching {
|
||||
prekeys?.seal(
|
||||
typedPayload,
|
||||
messageId,
|
||||
recipientNoiseKey,
|
||||
recipientAdvertisesPrekeys = allowsPrekeys
|
||||
)
|
||||
}.getOrNull() ?: return false
|
||||
val now = System.currentTimeMillis()
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(
|
||||
recipientNoiseKey,
|
||||
CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = now + CourierEnvelope.MAX_LIFETIME_MS,
|
||||
ciphertext = sealed.ciphertext,
|
||||
copies = 1,
|
||||
prekeyId = sealed.prekeyId
|
||||
)
|
||||
val encoded = envelope.encode() ?: return false
|
||||
if (encoded.size > MAX_DROP_BYTES) return false
|
||||
val dedupKey = senderDropKey(messageId, recipientNoiseKey)
|
||||
if (publishedDropKeys?.contains(dedupKey) == true) return true
|
||||
): CourierDepositResult =
|
||||
courierCoordinator?.deposit(content, messageId, recipientNoiseKey)
|
||||
?: CourierDepositResult.Rejected(CourierDepositResult.Reason.BRIDGE_DISABLED)
|
||||
|
||||
val relayConnected = relayManager?.isConnected?.value == true
|
||||
if (relayConnected) {
|
||||
scope.launch { publishOrQueueDrop(envelope, dedupKey) }
|
||||
return true
|
||||
}
|
||||
val gateway = availableBridgePeer()
|
||||
if (gateway != null) {
|
||||
currentMesh()?.sendCourierEnvelope(encoded, gateway.peerId)
|
||||
return true
|
||||
}
|
||||
scope.launch { enqueueDrop(PendingDrop(envelope, dedupKey)) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
// Clear persistent cryptographic and dedup material immediately. The
|
||||
// rest of the process-local bridge state remains serialized on scope.
|
||||
prekeys?.wipe()
|
||||
publishedDropKeys?.clear()
|
||||
seenDropEventIds?.clear()
|
||||
openedCourierMessageIds?.clear()
|
||||
scope.launch {
|
||||
suspend fun wipe() {
|
||||
courierCoordinator?.wipe()
|
||||
kotlinx.coroutines.withContext(dispatcher) {
|
||||
closeSubscriptions()
|
||||
queuedUplinks.clear()
|
||||
pendingDownlinks.clear()
|
||||
pendingDrops.clear()
|
||||
verifiedPeers.clear()
|
||||
publishVerifiedPeerSnapshot()
|
||||
participants.clear()
|
||||
publishedEventIds.clear()
|
||||
receivedEventIds.clear()
|
||||
@ -418,11 +353,23 @@ object MeshBridgeService {
|
||||
rebroadcastEventIds.clear()
|
||||
injectedEventIds.clear()
|
||||
radioMessageIds.clear()
|
||||
prekeyCoordinator?.wipe()
|
||||
_nearbyOnly.value = false
|
||||
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()
|
||||
@ -444,7 +391,7 @@ object MeshBridgeService {
|
||||
relayManager?.subscribe(
|
||||
filter = NostrFilter.bridgeRendezvous(
|
||||
cells,
|
||||
since = System.currentTimeMillis() - MAX_EVENT_AGE_MS
|
||||
since = clock() - MAX_EVENT_AGE_MS
|
||||
),
|
||||
id = BRIDGE_SUBSCRIPTION,
|
||||
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
|
||||
@ -459,7 +406,7 @@ object MeshBridgeService {
|
||||
return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION)
|
||||
}
|
||||
|
||||
private fun availableBridgePeer(): VerifiedPeer? =
|
||||
private fun availableBridgePeer(): VerifiedBridgePeer? =
|
||||
verifiedPeers.values.firstOrNull { peer ->
|
||||
peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true &&
|
||||
peer.bridgeCell != null &&
|
||||
@ -586,10 +533,10 @@ object MeshBridgeService {
|
||||
|
||||
private fun scheduleDownlink(jitter: Boolean) {
|
||||
if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return
|
||||
val now = System.currentTimeMillis()
|
||||
val now = clock()
|
||||
downlinkTimes.removeAll { now - it >= 60_000 }
|
||||
val waitMs = if (jitter) {
|
||||
Random.nextLong(200, 1_501)
|
||||
jitter(200, 1_501)
|
||||
} else {
|
||||
(downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50)
|
||||
}
|
||||
@ -600,7 +547,7 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
private fun drainDownlinks() {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = clock()
|
||||
downlinkTimes.removeAll { now - it >= 60_000 }
|
||||
while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) {
|
||||
val item = pendingDownlinks.removeAt(0)
|
||||
@ -619,7 +566,7 @@ object MeshBridgeService {
|
||||
)?.encode() ?: continue
|
||||
currentMesh()?.sendNostrCarrier(payload)
|
||||
rebroadcastEventIds.add(item.event.id)
|
||||
downlinkTimes += System.currentTimeMillis()
|
||||
downlinkTimes += clock()
|
||||
}
|
||||
if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false)
|
||||
}
|
||||
@ -654,16 +601,16 @@ object MeshBridgeService {
|
||||
pruneParticipants()
|
||||
if (_isEnabled.value) {
|
||||
refreshRendezvous()
|
||||
refreshCourierSubscription()
|
||||
courierCoordinator?.peerStateChanged()
|
||||
publishPresence()
|
||||
broadcastPrekeyBundle()
|
||||
prekeyCoordinator?.broadcast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordParticipant(pubkey: String, nickname: String?) {
|
||||
val now = System.currentTimeMillis()
|
||||
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)
|
||||
@ -678,7 +625,7 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
private fun pruneParticipants() {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = clock()
|
||||
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
|
||||
publishParticipants()
|
||||
}
|
||||
@ -688,7 +635,7 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
private fun allowUplink(depositor: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
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
|
||||
@ -697,7 +644,7 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
private fun allowInbound(signer: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = clock()
|
||||
inboundTimes.removeAll { now - it >= 60_000 }
|
||||
if (inboundTimes.size >= INBOUND_PER_MINUTE) return false
|
||||
val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() }
|
||||
@ -709,184 +656,16 @@ object MeshBridgeService {
|
||||
}
|
||||
|
||||
private fun allowSignatureAttempt(): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = clock()
|
||||
signatureAttemptTimes.removeAll { now - it >= 60_000 }
|
||||
if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false
|
||||
signatureAttemptTimes += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ingestPrekeyPacket(packet: BitchatPacket) {
|
||||
val bundle = PrekeyBundle.decode(packet.payload) ?: return
|
||||
val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey)
|
||||
val packetOwner = packet.senderID.toHex()
|
||||
if (owner != packetOwner) return
|
||||
val peer = verifiedPeers[owner]
|
||||
if (peer == null ||
|
||||
!peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)
|
||||
) {
|
||||
if (pendingPrekeyPackets.size < 64 || owner in pendingPrekeyPackets) {
|
||||
pendingPrekeyPackets[owner] = packet
|
||||
}
|
||||
return
|
||||
}
|
||||
val signature = packet.signature ?: return
|
||||
val signingData = packet.toBinaryDataForSigning() ?: return
|
||||
if (!verifyEd25519(signature, signingData, peer.signingKey)) return
|
||||
prekeys?.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey)
|
||||
}
|
||||
|
||||
private fun broadcastPrekeyBundle(force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastPrekeyBroadcastMs < PREKEY_REBROADCAST_MS) return
|
||||
val bundle = prekeys?.currentSignedBundle(now) ?: return
|
||||
val encoded = bundle.encode() ?: return
|
||||
lastPrekeyBroadcastMs = now
|
||||
currentMesh()?.sendPrekeyBundle(encoded)
|
||||
}
|
||||
|
||||
private fun refreshCourierSubscription() {
|
||||
if (!_isEnabled.value) return
|
||||
val identityKey = SecureIdentityStateManager(requireContext()).loadStaticKey()?.second ?: return
|
||||
val myTags = CourierEnvelope.candidateTags(identityKey).map { it.toHex() }.toSet()
|
||||
val peerTags = verifiedPeers.values
|
||||
.asSequence()
|
||||
.filter { currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true }
|
||||
.take(MAX_WATCHED_COURIER_PEERS)
|
||||
.flatMap {
|
||||
CourierEnvelope.candidateTags(it.noiseKey)
|
||||
.asSequence()
|
||||
.map { bytes -> bytes.toHex() }
|
||||
}
|
||||
.toSet()
|
||||
val allTags = myTags + peerTags
|
||||
if (allTags == subscribedCourierTags) return
|
||||
relayManager?.unsubscribe(COURIER_SUBSCRIPTION)
|
||||
subscribedCourierTags = allTags
|
||||
if (allTags.isEmpty()) return
|
||||
relayManager?.subscribe(
|
||||
filter = NostrFilter.courierDrops(
|
||||
allTags,
|
||||
since = System.currentTimeMillis() - CourierEnvelope.MAX_LIFETIME_MS
|
||||
),
|
||||
id = COURIER_SUBSCRIPTION,
|
||||
handler = { event -> scope.launch { handleDropEvent(event) } },
|
||||
targetRelayUrls = NostrRelayManager.defaultRelays()
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDropEvent(event: NostrEvent) {
|
||||
if (!_isEnabled.value ||
|
||||
event.kind != NostrKind.COURIER_DROP ||
|
||||
seenDropEventIds?.contains(event.id) == true ||
|
||||
!allowSignatureAttempt() ||
|
||||
!event.isValidSignature()
|
||||
) {
|
||||
return
|
||||
}
|
||||
val data = runCatching { Base64.decode(event.content, Base64.DEFAULT) }.getOrNull() ?: return
|
||||
if (data.size > MAX_DROP_BYTES) return
|
||||
val envelope = CourierEnvelope.decode(data) ?: return
|
||||
if (!validEnvelopeLifetime(envelope)) return
|
||||
val tagHex = envelope.recipientTag.toHex()
|
||||
if (event.tags.none { it.size >= 2 && it[0] == "x" && it[1] == tagHex }) return
|
||||
|
||||
if (isMyCourierTag(envelope.recipientTag)) {
|
||||
if (openCourierEnvelope(envelope)) {
|
||||
seenDropEventIds?.add(event.id, DROP_DEDUP_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
val peer = verifiedPeers.values
|
||||
.asSequence()
|
||||
.filter { currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true }
|
||||
.take(MAX_WATCHED_COURIER_PEERS)
|
||||
.firstOrNull {
|
||||
CourierEnvelope.candidateTags(it.noiseKey)
|
||||
.any { candidate -> candidate.contentEquals(envelope.recipientTag) }
|
||||
}
|
||||
if (peer != null) {
|
||||
currentMesh()?.sendCourierEnvelope(data, peer.peerId)
|
||||
seenDropEventIds?.add(event.id, DROP_DEDUP_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCourierEnvelope(envelope: CourierEnvelope): Boolean {
|
||||
val opened = runCatching {
|
||||
prekeys?.open(envelope.ciphertext, envelope.prekeyId)
|
||||
}.getOrNull() ?: return false
|
||||
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 (openedCourierMessageIds?.contains(privateMessage.messageID) == true) return true
|
||||
val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey)
|
||||
val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex())
|
||||
val message = BitchatMessage(
|
||||
id = privateMessage.messageID,
|
||||
sender = senderResolution.displayName ?: verifiedPeers[senderPeerId]?.nickname ?: "Unknown",
|
||||
content = privateMessage.content,
|
||||
timestamp = Date(),
|
||||
isPrivate = true,
|
||||
recipientNickname = currentMesh()?.myPeerID,
|
||||
senderPeerID = senderPeerId
|
||||
)
|
||||
AppStateStore.addPrivateMessage(
|
||||
ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey),
|
||||
message
|
||||
)
|
||||
openedCourierMessageIds?.add(privateMessage.messageID, DROP_DEDUP_MS)
|
||||
if (opened.consumedPrekey) broadcastPrekeyBundle(force = true)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun publishOrQueueDrop(envelope: CourierEnvelope, dedupKey: String?) {
|
||||
if (!validEnvelopeLifetime(envelope)) return
|
||||
if (relayManager?.isConnected?.value != true) {
|
||||
enqueueDrop(PendingDrop(envelope, dedupKey))
|
||||
return
|
||||
}
|
||||
val encoded = envelope.encode() ?: return
|
||||
if (encoded.size > MAX_DROP_BYTES) return
|
||||
val event = NostrProtocol.createCourierDropEvent(
|
||||
envelope = encoded,
|
||||
recipientTagHex = envelope.recipientTag.toHex(),
|
||||
expiresAtMs = envelope.expiry,
|
||||
senderIdentity = NostrIdentity.generate()
|
||||
)
|
||||
relayManager?.sendEvent(event, NostrRelayManager.defaultRelays())
|
||||
dedupKey?.let { publishedDropKeys?.add(it, DROP_DEDUP_MS) }
|
||||
}
|
||||
|
||||
private fun enqueueDrop(drop: PendingDrop) {
|
||||
if (drop.dedupKey != null && pendingDrops.any { it.dedupKey == drop.dedupKey }) return
|
||||
pendingDrops += drop
|
||||
while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0)
|
||||
}
|
||||
|
||||
private fun flushPendingDrops() {
|
||||
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
|
||||
val queued = pendingDrops.toList()
|
||||
pendingDrops.clear()
|
||||
queued.forEach { publishOrQueueDrop(it.envelope, it.dedupKey) }
|
||||
}
|
||||
|
||||
private fun isMyCourierTag(tag: ByteArray): Boolean {
|
||||
val ownKey = SecureIdentityStateManager(requireContext()).loadStaticKey()?.second ?: return false
|
||||
return CourierEnvelope.candidateTags(ownKey).any { it.contentEquals(tag) }
|
||||
}
|
||||
|
||||
private fun validEnvelopeLifetime(envelope: CourierEnvelope): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
return !envelope.isExpired(now) &&
|
||||
envelope.expiry > 0 &&
|
||||
envelope.expiry - now <= CourierEnvelope.MAX_LIFETIME_MS
|
||||
}
|
||||
|
||||
private fun closeSubscriptions() {
|
||||
relayManager?.unsubscribe(BRIDGE_SUBSCRIPTION)
|
||||
relayManager?.unsubscribe(COURIER_SUBSCRIPTION)
|
||||
subscribedCells = emptySet()
|
||||
subscribedCourierTags = emptySet()
|
||||
}
|
||||
|
||||
private fun isOwnEvent(event: NostrEvent, cell: String): Boolean =
|
||||
@ -896,7 +675,7 @@ object MeshBridgeService {
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun isFresh(event: NostrEvent): Boolean =
|
||||
abs(System.currentTimeMillis() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS
|
||||
abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS
|
||||
|
||||
private fun isValidGeohash(value: String): Boolean =
|
||||
value.length in 1..12 &&
|
||||
@ -905,26 +684,7 @@ object MeshBridgeService {
|
||||
private fun NostrEvent.tagValue(name: String): String? =
|
||||
tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
|
||||
|
||||
private fun senderDropKey(messageId: String, recipientNoiseKey: ByteArray): String {
|
||||
val material = recipientNoiseKey.toHex() + "|" + messageId
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(material.toByteArray(Charsets.UTF_8))
|
||||
.toHex()
|
||||
}
|
||||
|
||||
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 currentMesh(): MeshService? =
|
||||
MeshServiceHolder.unifiedMeshService
|
||||
?: appContext?.let { context ->
|
||||
runCatching { MeshServiceHolder.getUnifiedOrCreate(context) }.getOrNull()
|
||||
}
|
||||
private fun currentMesh(): MeshService? = meshProvider()
|
||||
|
||||
private fun requireContext(): Context =
|
||||
checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" }
|
||||
@ -944,61 +704,4 @@ object MeshBridgeService {
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private class BoundedIdSet(private val capacity: Int) {
|
||||
private val values = LinkedHashSet<String>()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private class PersistentExpiringIdSet(
|
||||
private val preferences: android.content.SharedPreferences,
|
||||
private val key: String,
|
||||
private val capacity: Int
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val values: LinkedHashMap<String, Long> = load()
|
||||
|
||||
fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
prune(nowMs)
|
||||
return (values[id] ?: return false) > nowMs
|
||||
}
|
||||
|
||||
fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) {
|
||||
prune(nowMs)
|
||||
values.remove(id)
|
||||
values[id] = nowMs + lifetimeMs
|
||||
while (values.size > capacity) values.remove(values.keys.first())
|
||||
persist()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
values.clear()
|
||||
preferences.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun prune(nowMs: Long) {
|
||||
val changed = values.entries.removeAll { it.value <= nowMs }
|
||||
if (changed) persist()
|
||||
}
|
||||
|
||||
private fun load(): LinkedHashMap<String, Long> {
|
||||
val type = object : TypeToken<Map<String, Long>>() {}.type
|
||||
val decoded: Map<String, Long> = runCatching {
|
||||
preferences.getString(key, null)
|
||||
?.let { json -> gson.fromJson<Map<String, Long>>(json, type) }
|
||||
}.getOrNull() ?: emptyMap()
|
||||
return LinkedHashMap(decoded)
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
preferences.edit { putString(key, gson.toJson(values)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
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
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
|
||||
/**
|
||||
* 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<VerifiedBridgePeer>,
|
||||
private val clock: () -> Long = System::currentTimeMillis
|
||||
) {
|
||||
private val pendingPackets = linkedMapOf<String, BitchatPacket>()
|
||||
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
|
||||
) {
|
||||
val signature = packet.signature ?: return
|
||||
val signingData = packet.toBinaryDataForSigning() ?: return
|
||||
if (!verifyEd25519(signature, signingData, 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 {
|
||||
const val MAX_PENDING_PACKETS = 64
|
||||
const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,9 @@ package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.noise.CourierNoiseCrypto
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
@ -22,7 +18,12 @@ import java.security.SecureRandom
|
||||
* but their consumption assignments are persisted so retries of one message
|
||||
* never spend additional prekeys.
|
||||
*/
|
||||
class PrekeyManager private constructor(context: Context) {
|
||||
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?
|
||||
@ -34,40 +35,13 @@ class PrekeyManager private constructor(context: Context) {
|
||||
val consumedPrekey: Boolean
|
||||
)
|
||||
|
||||
private data class LocalRecord(
|
||||
val id: Long,
|
||||
val privateKey: String,
|
||||
val createdAt: Long,
|
||||
var consumedAt: Long? = null
|
||||
)
|
||||
|
||||
private data class PersistedLocal(
|
||||
var records: MutableList<LocalRecord> = mutableListOf(),
|
||||
var nextId: Long = 0,
|
||||
var generatedAt: Long = 0
|
||||
)
|
||||
|
||||
private data class StoredBundle(
|
||||
val noiseKey: String,
|
||||
var generatedAt: Long,
|
||||
var prekeyIds: List<Long>,
|
||||
var prekeyPublicKeys: List<String>,
|
||||
var usedIds: MutableSet<Long>,
|
||||
var assignments: MutableMap<String, Long>,
|
||||
var updatedAt: Long
|
||||
)
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val identityState = SecureIdentityStateManager(appContext)
|
||||
private val peerPrefs = appContext.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
private val lock = Any()
|
||||
private var local: PersistedLocal? = null
|
||||
private var peerBundles: MutableMap<String, StoredBundle>? = null
|
||||
private var local: LocalPrekeyState? = null
|
||||
private var peerBundles: MutableMap<String, StoredPeerPrekeyBundle>? = null
|
||||
|
||||
fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) {
|
||||
val staticKey = identityState.loadStaticKey()?.second ?: return@synchronized null
|
||||
val signingPrivateKey = identityState.loadSigningKey()?.first ?: return@synchronized null
|
||||
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
|
||||
@ -112,7 +86,7 @@ class PrekeyManager private constructor(context: Context) {
|
||||
if (existing != null && existing.generatedAt >= bundle.generatedAt) return false
|
||||
|
||||
val freshIds = bundle.prekeys.map { it.id }.toSet()
|
||||
bundles[key] = StoredBundle(
|
||||
bundles[key] = StoredPeerPrekeyBundle(
|
||||
noiseKey = key,
|
||||
generatedAt = bundle.generatedAt,
|
||||
prekeyIds = bundle.prekeys.map { it.id },
|
||||
@ -138,7 +112,7 @@ class PrekeyManager private constructor(context: Context) {
|
||||
recipientAdvertisesPrekeys: Boolean,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Sealed {
|
||||
val senderPrivateKey = identityState.loadStaticKey()?.first
|
||||
val senderPrivateKey = identity.staticKey()?.first
|
||||
?: throw IllegalStateException("Noise static identity is unavailable")
|
||||
val assigned = if (recipientAdvertisesPrekeys) {
|
||||
assignPrekey(messageId, recipientNoiseKey, nowMs)
|
||||
@ -164,7 +138,7 @@ class PrekeyManager private constructor(context: Context) {
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Opened {
|
||||
if (prekeyId == null) {
|
||||
val staticPrivateKey = identityState.loadStaticKey()?.first
|
||||
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)
|
||||
@ -202,10 +176,10 @@ class PrekeyManager private constructor(context: Context) {
|
||||
}
|
||||
|
||||
fun wipe() = synchronized(lock) {
|
||||
local = PersistedLocal()
|
||||
local = LocalPrekeyState()
|
||||
peerBundles = mutableMapOf()
|
||||
identityState.clearSecureValues(LOCAL_STORE_KEY)
|
||||
peerPrefs.edit { clear() }
|
||||
localStore.clear()
|
||||
peerStore.clear()
|
||||
}
|
||||
|
||||
private fun assignPrekey(
|
||||
@ -239,17 +213,17 @@ class PrekeyManager private constructor(context: Context) {
|
||||
PrekeyBundle.Prekey(id, publicKey)
|
||||
}
|
||||
|
||||
private fun replenishLocked(state: PersistedLocal, nowMs: Long): Boolean {
|
||||
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) {
|
||||
val random = SecureRandom()
|
||||
repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) {
|
||||
val privateKey = ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes)
|
||||
state.records += LocalRecord(
|
||||
val privateKey = randomBytes()
|
||||
require(privateKey.size == PrekeyBundle.KEY_LENGTH)
|
||||
state.records += LocalPrekeyRecord(
|
||||
id = state.nextId and 0xFFFF_FFFFL,
|
||||
privateKey = encode(privateKey),
|
||||
createdAt = nowMs
|
||||
@ -263,50 +237,38 @@ class PrekeyManager private constructor(context: Context) {
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun pruneLocked(state: PersistedLocal, nowMs: Long) {
|
||||
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: PersistedLocal, nowMs: Long) {
|
||||
private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) {
|
||||
state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1)
|
||||
}
|
||||
|
||||
private fun loadLocalLocked(): PersistedLocal {
|
||||
private fun loadLocalLocked(): LocalPrekeyState {
|
||||
local?.let { return it }
|
||||
val loaded = runCatching {
|
||||
identityState.getSecureValue(LOCAL_STORE_KEY)
|
||||
?.let { gson.fromJson(it, PersistedLocal::class.java) }
|
||||
}.getOrNull() ?: PersistedLocal()
|
||||
val loaded = localStore.load()
|
||||
local = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private fun persistLocalLocked(state: PersistedLocal) {
|
||||
runCatching { identityState.storeSecureValue(LOCAL_STORE_KEY, gson.toJson(state)) }
|
||||
.onFailure { Log.e(TAG, "Failed to persist local prekeys", it) }
|
||||
private fun persistLocalLocked(state: LocalPrekeyState) {
|
||||
localStore.save(state)
|
||||
}
|
||||
|
||||
private fun loadPeerBundlesLocked(): MutableMap<String, StoredBundle> {
|
||||
private fun loadPeerBundlesLocked(): MutableMap<String, StoredPeerPrekeyBundle> {
|
||||
peerBundles?.let { return it }
|
||||
val type = object : TypeToken<List<StoredBundle>>() {}.type
|
||||
val values: List<StoredBundle> = runCatching {
|
||||
peerPrefs.getString(PEER_BUNDLES_KEY, null)
|
||||
?.let { json -> gson.fromJson<List<StoredBundle>>(json, type) }
|
||||
}.getOrNull() ?: emptyList()
|
||||
return values
|
||||
.filter { it.prekeyIds.size == it.prekeyPublicKeys.size }
|
||||
.associateByTo(mutableMapOf()) { it.noiseKey }
|
||||
.also { peerBundles = it }
|
||||
return peerStore.load().also { peerBundles = it }
|
||||
}
|
||||
|
||||
private fun persistPeerBundlesLocked(bundles: MutableMap<String, StoredBundle>) {
|
||||
peerPrefs.edit { putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())) }
|
||||
private fun persistPeerBundlesLocked(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
peerStore.save(bundles)
|
||||
}
|
||||
|
||||
private fun isFresh(bundle: StoredBundle, nowMs: Long): Boolean =
|
||||
private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean =
|
||||
nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS
|
||||
|
||||
private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching {
|
||||
@ -331,10 +293,7 @@ class PrekeyManager private constructor(context: Context) {
|
||||
runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PrekeyManager"
|
||||
private const val LOCAL_STORE_KEY = "courier_prekeys_v1"
|
||||
private const val PEER_PREFS = "bitchat_prekey_bundles"
|
||||
private const val PEER_BUNDLES_KEY = "bundles_v1"
|
||||
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
|
||||
@ -346,7 +305,21 @@ class PrekeyManager private constructor(context: Context) {
|
||||
|
||||
fun getInstance(context: Context): PrekeyManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: PrekeyManager(context).also { instance = it }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
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<LocalPrekeyRecord> = mutableListOf(),
|
||||
var nextId: Long = 0,
|
||||
var generatedAt: Long = 0
|
||||
)
|
||||
|
||||
internal data class StoredPeerPrekeyBundle(
|
||||
val noiseKey: String,
|
||||
var generatedAt: Long,
|
||||
var prekeyIds: List<Long>,
|
||||
var prekeyPublicKeys: List<String>,
|
||||
var usedIds: MutableSet<Long>,
|
||||
var assignments: MutableMap<String, Long>,
|
||||
var updatedAt: Long
|
||||
)
|
||||
|
||||
internal interface PrekeyIdentity {
|
||||
fun staticKey(): Pair<ByteArray, ByteArray>?
|
||||
fun signingKey(): Pair<ByteArray, ByteArray>?
|
||||
}
|
||||
|
||||
internal interface LocalPrekeyStore {
|
||||
fun load(): LocalPrekeyState
|
||||
fun save(state: LocalPrekeyState)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal interface PeerPrekeyStore {
|
||||
fun load(): MutableMap<String, StoredPeerPrekeyBundle>
|
||||
fun save(bundles: Map<String, StoredPeerPrekeyBundle>)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal class AndroidPrekeyIdentity(
|
||||
private val state: SecureIdentityStateManager
|
||||
) : PrekeyIdentity {
|
||||
override fun staticKey(): Pair<ByteArray, ByteArray>? = state.loadStaticKey()
|
||||
override fun signingKey(): Pair<ByteArray, ByteArray>? = 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) {
|
||||
runCatching {
|
||||
this.state.storeSecureValue(LOCAL_STORE_KEY, gson.toJson(state))
|
||||
}.onFailure { Log.e(TAG, "Failed to persist local prekeys", it) }
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
state.clearSecureValues(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<String, StoredPeerPrekeyBundle> {
|
||||
val type = object : TypeToken<List<StoredPeerPrekeyBundle>>() {}.type
|
||||
val values: List<StoredPeerPrekeyBundle> = runCatching {
|
||||
preferences.getString(PEER_BUNDLES_KEY, null)
|
||||
?.let { json -> gson.fromJson<List<StoredPeerPrekeyBundle>>(json, type) }
|
||||
}.getOrNull() ?: emptyList()
|
||||
return values
|
||||
.filter { it.prekeyIds.size == it.prekeyPublicKeys.size }
|
||||
.associateByTo(mutableMapOf()) { it.noiseKey }
|
||||
}
|
||||
|
||||
override fun save(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
preferences.edit {
|
||||
putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
preferences.edit { clear() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PEER_BUNDLES_KEY = "bundles_v1"
|
||||
}
|
||||
}
|
||||
@ -35,7 +35,6 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.services.bridge.MeshBridgeService
|
||||
|
||||
/**
|
||||
* Feature row for displaying app capabilities
|
||||
@ -200,8 +199,10 @@ private fun SettingsToggleRow(
|
||||
fun AboutSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onShowDebug: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
bridgeEnabled: Boolean,
|
||||
onBridgeEnabledChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowDebug: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@ -227,8 +228,6 @@ fun AboutSheet(
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val bridgeEnabled by MeshBridgeService.isEnabled.collectAsState()
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
@ -417,7 +416,7 @@ fun AboutSheet(
|
||||
title = stringResource(R.string.mesh_bridge_title),
|
||||
subtitle = stringResource(R.string.mesh_bridge_description),
|
||||
checked = bridgeEnabled,
|
||||
onCheckedChange = MeshBridgeService::setEnabled
|
||||
onCheckedChange = onBridgeEnabledChange
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
|
||||
@ -342,8 +342,7 @@ private fun MainHeader(
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val bridgeEnabled by com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collectAsStateWithLifecycle()
|
||||
val bridgedParticipants by com.bitchat.android.services.bridge.MeshBridgeService.bridgedParticipants.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Bookmarks store for current geohash toggle (iOS parity)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
@ -447,7 +446,7 @@ private fun MainHeader(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
if (bridgeEnabled) {
|
||||
if (bridgeUiState.enabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_mesh_bridge_active),
|
||||
@ -458,7 +457,7 @@ private fun MainHeader(
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
bridgedPeopleCount = bridgedParticipants.size,
|
||||
bridgedPeopleCount = bridgeUiState.participants.size,
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
|
||||
@ -60,8 +60,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
|
||||
val bridgeEnabled by com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collectAsStateWithLifecycle()
|
||||
val nearbyOnly by com.bitchat.android.services.bridge.MeshBridgeService.nearbyOnly.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@ -240,11 +239,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = showMediaButtons,
|
||||
showBridgeControls = bridgeEnabled &&
|
||||
showBridgeControls = bridgeUiState.enabled &&
|
||||
currentChannel == null &&
|
||||
selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location,
|
||||
nearbyOnly = nearbyOnly,
|
||||
onNearbyOnlyChange = com.bitchat.android.services.bridge.MeshBridgeService::setNearbyOnly
|
||||
nearbyOnly = bridgeUiState.nearbyOnly,
|
||||
onNearbyOnlyChange = viewModel::setBridgeNearbyOnly
|
||||
)
|
||||
}
|
||||
|
||||
@ -532,6 +531,7 @@ private fun ChatDialogs(
|
||||
onMeshPeerListDismiss: () -> Unit,
|
||||
) {
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@ -548,7 +548,9 @@ private fun ChatDialogs(
|
||||
AboutSheet(
|
||||
isPresented = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss,
|
||||
onShowDebug = { showDebugSheet = true }
|
||||
onShowDebug = { showDebugSheet = true },
|
||||
bridgeEnabled = bridgeUiState.enabled,
|
||||
onBridgeEnabledChange = viewModel::setBridgeEnabled
|
||||
)
|
||||
if (showDebugSheet) {
|
||||
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
||||
|
||||
@ -9,6 +9,9 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
@ -30,6 +33,8 @@ 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
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@ -202,6 +207,21 @@ class ChatViewModel(
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val bridgeUiState: StateFlow<BridgeUiState> = 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
|
||||
@ -340,7 +360,6 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Note: Mesh service lifecycle is now managed by MainActivity
|
||||
}
|
||||
|
||||
@ -852,6 +871,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)
|
||||
}
|
||||
@ -954,6 +981,12 @@ class ChatViewModel(
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
fun panicClearAllData() {
|
||||
viewModelScope.launch {
|
||||
panicClearAllDataInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun panicClearAllDataInternal() {
|
||||
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
|
||||
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
|
||||
@ -40,7 +40,7 @@ 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.MeshBridgeService
|
||||
import com.bitchat.android.services.bridge.BridgedParticipant
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
|
||||
|
||||
@ -69,8 +69,7 @@ fun MeshPeerListSheet(
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val bridgeEnabled by MeshBridgeService.isEnabled.collectAsStateWithLifecycle()
|
||||
val bridgedParticipants by MeshBridgeService.bridgedParticipants.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
|
||||
@ -183,9 +182,9 @@ fun MeshPeerListSheet(
|
||||
}
|
||||
)
|
||||
|
||||
if (bridgeEnabled && bridgedParticipants.isNotEmpty()) {
|
||||
if (bridgeUiState.enabled && bridgeUiState.participants.isNotEmpty()) {
|
||||
BridgedPeopleSection(
|
||||
participants = bridgedParticipants,
|
||||
participants = bridgeUiState.participants,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
@ -225,7 +224,7 @@ fun MeshPeerListSheet(
|
||||
|
||||
@Composable
|
||||
private fun BridgedPeopleSection(
|
||||
participants: List<MeshBridgeService.BridgedParticipant>,
|
||||
participants: List<BridgedParticipant>,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
|
||||
@ -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))))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class NostrPublishTrackerTest {
|
||||
@Test
|
||||
fun `first relay acceptance completes publication`() = runBlocking {
|
||||
val tracker = NostrPublishTracker()
|
||||
val result = tracker.begin("event", setOf("relay-a", "relay-b"))
|
||||
|
||||
tracker.record("event", "relay-a", accepted = true, message = null)
|
||||
|
||||
assertEquals(NostrPublishResult.Accepted("relay-a"), result.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `publication is rejected only after every target rejects`() = runBlocking {
|
||||
val tracker = NostrPublishTracker()
|
||||
val result = tracker.begin("event", setOf("relay-a", "relay-b"))
|
||||
|
||||
tracker.record("event", "relay-a", accepted = false, message = "duplicate")
|
||||
assertFalse(result.isCompleted)
|
||||
tracker.record("event", "relay-b", accepted = false, message = "blocked")
|
||||
|
||||
assertEquals(
|
||||
NostrPublishResult.Rejected(
|
||||
mapOf("relay-a" to "duplicate", "relay-b" to "blocked")
|
||||
),
|
||||
result.await()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<ByteArray, ByteArray>,
|
||||
private val signing: Pair<ByteArray, ByteArray>
|
||||
) : PrekeyIdentity {
|
||||
override fun staticKey(): Pair<ByteArray, ByteArray> = static
|
||||
override fun signingKey(): Pair<ByteArray, ByteArray> = 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<String, StoredPeerPrekeyBundle>()
|
||||
|
||||
override fun load(): MutableMap<String, StoredPeerPrekeyBundle> = bundles
|
||||
|
||||
override fun save(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
this.bundles = bundles.toMutableMap()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
bundles.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user