refactor: harden bridge delivery boundaries

This commit is contained in:
callebtc 2026-07-27 01:17:31 +02:00
parent b0f3bd34fc
commit 7800d4bca6
31 changed files with 1683 additions and 711 deletions

View File

@ -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

View File

@ -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) {

View 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)
}
}

View File

@ -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(

View File

@ -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}")
}

View File

@ -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 -> {

View File

@ -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 =

View File

@ -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 =

View File

@ -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)

View 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
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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")
}
}

View File

@ -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()
}
}

View File

@ -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")
}
}

View File

@ -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)) }
}
}

View File

@ -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
)

View File

@ -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()
)
}

View File

@ -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
}
}

View File

@ -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)) }
}
}
}

View File

@ -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
}
}

View File

@ -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 }
}
}
}
}

View File

@ -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"
}
}

View File

@ -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(

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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)) {

View File

@ -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))))
}
}

View File

@ -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()
)
}
}

View File

@ -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()
}
}
}