Integrate opt-in mesh bridging and one-time courier prekeys

This commit is contained in:
callebtc 2026-09-07 23:52:30 +03:00
parent 6cc437d172
commit 2243b4f416
55 changed files with 3263 additions and 86 deletions

View File

@ -73,6 +73,15 @@ class BitchatApplication : Application() {
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
// Bridge policy is process-scoped so rendezvous and courier delivery
// continue while the activity is backgrounded.
try {
com.bitchat.android.services.bridge.MeshBridgeService.initialize(this)
com.bitchat.android.mesh.BridgeMeshPort.install(
com.bitchat.android.services.bridge.MeshBridgeService
)
} catch (_: Exception) { }
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }

View File

@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Refresh available channels from current location
*/
fun refreshChannels() {
fun refreshChannels(
forceFresh: Boolean = false,
updatePlaceNames: Boolean = true
) {
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
requestOneShotLocation()
requestOneShotLocation(forceFresh, updatePlaceNames)
}
}
@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) {
// MARK: - Location Operations
private fun requestOneShotLocation() {
private fun requestOneShotLocation(
forceFresh: Boolean = false,
updatePlaceNames: Boolean = true
) {
if (!isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) {
val token = LiveLocationPrivacyGate.captureToken() ?: return
_isLoadingLocation.value = true
if (forceFresh) {
requestFreshLocation(token, updatePlaceNames)
return
}
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.getLastKnownLocation { cached ->
if (!canUseLiveLocation(token)) return@getLastKnownLocation
if (cached != null) {
onLocationUpdated(cached, token)
onLocationUpdated(cached, token, updatePlaceNames)
} else {
LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
requestFreshLocation(token, updatePlaceNames)
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(location: Location, token: Long) {
private fun requestFreshLocation(
token: Long,
updatePlaceNames: Boolean
) {
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token, updatePlaceNames)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(
location: Location,
token: Long,
updatePlaceNames: Boolean = true
) {
LiveLocationPrivacyGate.runIfAllowed(token) {
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
_isLoadingLocation.value = false
computeChannels(location, token)
reverseGeocodeIfNeeded(location, token)
if (updatePlaceNames) reverseGeocodeIfNeeded(location, token)
}
}
@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) {
_teleported.value = false
}
/** Remove exact/transient location state without destroying the singleton. */
fun panicReset() {
clearLiveLocationState()
clearPersistedChannel()
}
// MARK: - Location Services State Persistence
/**

View File

@ -696,6 +696,10 @@ class SecureIdentityStateManager {
/**
* Store a string value in secure preferences
*/
/** Persist keys and consumption state before making their use observable. */
fun storeSecureValueSynchronously(key: String, value: String): Boolean =
prefs.edit().putString(key, value).commit()
fun storeSecureValue(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}

View File

@ -9,7 +9,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
@ -21,6 +20,7 @@ import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.*
@ -74,7 +74,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
PeerCapabilities.localSupported(),
requireNotNull(encryptionService.getSigningPublicKey())
)
},
@ -203,7 +203,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
if (isBleTransportEnabled()) {
TransportBridgeService.register("BLE", this)
}
bridgeCourierService.start()
serviceScope.launch {
com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collect { enabled ->
if (enabled) bridgeCourierService.start() else bridgeCourierService.stop()
}
}
// Inject dynamic direct connection check into PeerManager
// Matches iOS logic: checks if we have an active hardware mapping for this peer
@ -433,7 +437,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getBroadcastRecipient(): ByteArray {
return SpecialRecipients.BROADCAST
}
// Cryptographic operations
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
return securityManager.verifySignature(packet, peerID)
@ -629,6 +633,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getBroadcastRecipient(): ByteArray {
return SpecialRecipients.BROADCAST
}
override fun isPeerDirectlyConnected(peerID: String): Boolean =
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed) }
@ -936,6 +943,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
if (content.isEmpty()) return
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
serviceScope.launch {
val packet = BitchatPacket(
@ -954,6 +962,57 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
broadcastRoutedPacket(RoutedPacket(signedPacket))
// Track our own broadcast message for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
if (channel == null) {
val nickname = runCatching {
com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID)
}.getOrNull()
BridgeMeshPort.bridgeOutgoing(
content,
myPeerID,
packet.timestamp.toLong(),
nickname,
bridgePolicyAtSend
)
}
}
}
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
sendRawProtocolPacket(MessageType.NOSTR_CARRIER, payload, recipientPeerID, sign = true)
}
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true)
}
fun sendPrekeyBundle(payload: ByteArray) {
sendRawProtocolPacket(MessageType.PREKEY_BUNDLE, payload, null, sign = true)
}
private fun sendRawProtocolPacket(
type: MessageType,
payload: ByteArray,
recipientPeerID: String?,
sign: Boolean
) {
if (payload.isEmpty()) return
serviceScope.launch {
val packet = BridgeProtocolPacketFactory.protocolPacket(
type = type,
payload = payload,
senderPeerId = myPeerID,
recipientPeerId = recipientPeerID,
ttl = MAX_TTL
) ?: return@launch
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
if (sign &&
type != MessageType.COURIER_ENVELOPE &&
outgoing.signature?.size != 64
) {
return@launch
}
gossipSyncManager.onPublicPacketSeen(outgoing)
broadcastRoutedPacket(RoutedPacket(outgoing))
}
}
@ -1418,7 +1477,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode announcement as TLV")
@ -1480,7 +1543,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode peer announcement as TLV")
@ -1622,7 +1689,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
privateMessage
).encode()
val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return emptyList() }
val sealed = try { com.bitchat.android.services.bridge.PrekeyManager.getInstance(context).seal(typedPayload, messageID, recipientNoiseKey, true) } catch (_: Exception) { return emptyList() }
val now = System.currentTimeMillis()
val couriers = courierPeerIDs.distinct().take(4)
if (couriers.isEmpty()) return emptyList()
@ -1633,7 +1700,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
com.bitchat.android.model.CourierEnvelope.epochDay(now)
),
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = sealed,
ciphertext = sealed.ciphertext,
prekeyID = sealed.prekeyId?.toUInt(),
copies = 4u
)
val payload = envelope.encode() ?: return@filter false
@ -1674,7 +1742,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
if (envelope.expiry.toLong() <= now) return false
val localKey = encryptionService.getStaticPublicKey() ?: return false
if (envelope.matchesRecipient(localKey, now)) {
val (senderKey, typedPayload) = try { encryptionService.openCourierPayload(envelope.ciphertext) } catch (_: Exception) { return false }
val opened = try {
com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)
.open(envelope.ciphertext, envelope.prekeyID?.toLong())
} catch (_: Exception) { return false }
val senderKey = opened.senderStaticKey
val typedPayload = opened.payload
if (opened.consumedPrekey) {
com.bitchat.android.services.bridge.MeshBridgeService.refreshPrekeys()
}
val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false
if (noisePayload.type !in setOf(
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,

View File

@ -0,0 +1,98 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.protocol.BitchatPacket
/**
* Immutable privacy decision captured when a public message is accepted.
*
* A later opt-in must not authorize a message that was composed while
* bridging was disabled or nearby-only. Publication therefore requires both
* this send-time decision and the current policy to allow bridging.
*/
class BridgeOutboundPolicy internal constructor(
internal val enabled: Boolean,
internal val nearbyOnly: Boolean
) {
val allowsBridging: Boolean
get() = enabled && !nearbyOnly
fun permitsPublication(current: BridgeOutboundPolicy): Boolean =
allowsBridging && current.allowsBridging
companion object {
val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false)
internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy =
BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly)
}
}
/**
* Transport-facing bridge boundary.
*
* BLE/Wi-Fi packet code depends only on this protocol surface; application
* bootstrap installs the process bridge controller. Tests can install a fake
* without constructing relay or persistence infrastructure.
*/
interface BridgeMeshDelegate {
fun advertisedCell(): String?
fun outboundPolicy(): BridgeOutboundPolicy
fun bridgeOutgoing(
content: String,
senderPeerId: String,
timestampMs: Long,
nickname: String?,
policyAtSend: BridgeOutboundPolicy
)
fun handleAuthenticatedRadioMessage(messageId: String)
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
fun handlePrekeyPacket(packet: BitchatPacket)
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
}
object BridgeMeshPort : BridgeMeshDelegate {
@Volatile
private var delegate: BridgeMeshDelegate? = null
fun install(delegate: BridgeMeshDelegate) {
this.delegate = delegate
}
override fun advertisedCell(): String? = delegate?.advertisedCell()
override fun outboundPolicy(): BridgeOutboundPolicy =
delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied
override fun bridgeOutgoing(
content: String,
senderPeerId: String,
timestampMs: Long,
nickname: String?,
policyAtSend: BridgeOutboundPolicy
) {
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend)
}
override fun handleAuthenticatedRadioMessage(messageId: String) {
delegate?.handleAuthenticatedRadioMessage(messageId)
}
override fun handleVerifiedAnnouncement(
peerId: String,
announcement: IdentityAnnouncement
) {
delegate?.handleVerifiedAnnouncement(peerId, announcement)
}
override fun handlePrekeyPacket(packet: BitchatPacket) {
delegate?.handlePrekeyPacket(packet)
}
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
delegate?.handleCarrier(payload, fromPeerId, directedToUs)
}
}

View File

@ -19,6 +19,7 @@ import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
@ -69,7 +70,7 @@ class MeshCore(
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
PeerCapabilities.localSupported(),
requireNotNull(encryptionService.getSigningPublicKey())
)
},
@ -508,6 +509,9 @@ class MeshCore(
return SpecialRecipients.BROADCAST
}
override fun isPeerDirectlyConnected(peerID: String): Boolean =
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed) }
}
@ -592,6 +596,7 @@ class MeshCore(
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
if (content.isEmpty()) return
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
scope.launch {
val packet = BitchatPacket(
version = 1u,
@ -606,6 +611,70 @@ class MeshCore(
val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket))
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
if (channel == null) {
val nickname = hooks.announcementNicknameProvider?.invoke()
?: delegate?.getNickname()
BridgeMeshPort.bridgeOutgoing(
content,
myPeerID,
packet.timestamp.toLong(),
nickname,
bridgePolicyAtSend
)
}
}
}
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) {
sendRawProtocolPacket(
type = MessageType.NOSTR_CARRIER,
payload = payload,
recipientPeerID = recipientPeerID,
sign = true
)
}
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
sendRawProtocolPacket(
type = MessageType.COURIER_ENVELOPE,
payload = payload,
recipientPeerID = recipientPeerID,
sign = true
)
}
fun sendPrekeyBundle(payload: ByteArray) {
sendRawProtocolPacket(
type = MessageType.PREKEY_BUNDLE,
payload = payload,
recipientPeerID = null,
sign = true
)
}
private fun sendRawProtocolPacket(
type: MessageType,
payload: ByteArray,
recipientPeerID: String?,
sign: Boolean
) {
if (payload.isEmpty()) return
scope.launch {
val packet = BridgeProtocolPacketFactory.protocolPacket(
type = type,
payload = payload,
senderPeerId = myPeerID,
recipientPeerId = recipientPeerID,
ttl = maxTtl
) ?: return@launch
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
if (sign &&
type != MessageType.COURIER_ENVELOPE &&
outgoing.signature?.size != 64
) {
return@launch
}
dispatchGlobal(RoutedPacket(outgoing))
}
}
@ -976,7 +1045,11 @@ class MeshCore(
Log.e("MeshCore", "No signing public key available for announcement")
return@launch
}
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
val announcePacket = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@ -997,7 +1070,11 @@ class MeshCore(
?: myPeerID
val staticKey = encryptionService.getStaticPublicKey() ?: return
val signingKey = encryptionService.getSigningPublicKey() ?: return
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,

View File

@ -13,6 +13,9 @@ interface MeshService {
fun stopServices()
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null)
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String)
fun sendPrekeyBundle(payload: ByteArray)
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}

View File

@ -9,6 +9,7 @@ import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.util.toHexString
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoiceScope
@ -398,6 +399,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
capabilities = announcement.capabilities
) ?: false
BridgeMeshPort.handleVerifiedAnnouncement(
peerID,
announcement
)
// Update mesh graph from gossip neighbors (only if TLV present)
try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
@ -542,13 +548,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Fallback: plain text
val message = BitchatMessage(
id = PacketIdUtil.computeIdHex(packet).uppercase(),
id = MeshMessageIdentity.stableId(
peerID,
packet.timestamp.toLong(),
String(packet.payload, Charsets.UTF_8)
),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
BridgeMeshPort.handleAuthenticatedRadioMessage(message.id)
} catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
}

View File

@ -158,6 +158,21 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
MessageType.PREKEY_BUNDLE -> {
BridgeMeshPort.handlePrekeyPacket(packet)
}
MessageType.NOSTR_CARRIER -> {
val directedToUs = packetRelayManager.isPacketAddressedToMe(packet)
val isBroadcast = packet.recipientID == null ||
packet.recipientID.contentEquals(delegate?.getBroadcastRecipient())
if (directedToUs || isBroadcast) {
BridgeMeshPort.handleCarrier(
packet.payload,
peerID,
directedToUs
)
}
}
else -> {
// Handle private packet types (address check required)
if (packetRelayManager.isPacketAddressedToMe(packet)) {
@ -332,6 +347,7 @@ interface PacketProcessorDelegate {
// Network information
fun getNetworkSize(): Int
fun getBroadcastRecipient(): ByteArray
fun isPeerDirectlyConnected(peerID: String): Boolean = false
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean

View File

@ -271,7 +271,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME,
MessageType.LEAVE,
MessageType.REQUEST_SYNC
MessageType.REQUEST_SYNC,
MessageType.NOSTR_CARRIER
)) {
return true
}

View File

@ -101,6 +101,29 @@ class UnifiedMeshService(
}
}
override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
when {
isBleEnabled() -> bluetooth.sendNostrCarrier(payload, recipientPeerID)
else -> wifiService()?.sendNostrCarrier(payload, recipientPeerID)
}
}
override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
when {
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.sendCourierEnvelope(payload, recipientPeerID)
else -> wifiService()?.sendCourierEnvelope(payload, recipientPeerID)
}
}
override fun sendPrekeyBundle(payload: ByteArray) {
if (isBleEnabled()) {
bluetooth.sendPrekeyBundle(payload)
} else {
wifiService()?.sendPrekeyBundle(payload)
}
}
override fun sendPrivateMessage(
content: String,
recipientPeerID: String,

View File

@ -81,7 +81,15 @@ data class BitchatMessage(
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
* mesh IDs and private-chat routing aliases.
*/
val senderNostrPubkey: String? = null
val senderNostrPubkey: String? = null,
/** Rendered from a signed bridge rendezvous event rather than local radio. */
val isBridged: Boolean = false,
/**
* Untrusted radio-coordinate hint from the bridge event. It may merge a
* duplicate when the authenticated radio copy arrives, but never owns the
* bridge row's primary ID.
*/
val bridgeRadioMessageIdHint: String? = null
) : Parcelable {
/**

View File

@ -13,7 +13,8 @@ data class IdentityAnnouncement(
val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement)
val signingPublicKey: ByteArray, // Ed25519 public key for signing
val capabilities: PeerCapabilities? = null,
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList()
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList(),
val bridgeGeohash: String? = null
) : Parcelable {
/**
@ -23,7 +24,8 @@ data class IdentityAnnouncement(
NICKNAME(0x01u),
NOISE_PUBLIC_KEY(0x02u),
SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key
CAPABILITIES(0x05u);
CAPABILITIES(0x05u),
BRIDGE_GEOHASH(0x06u);
companion object {
fun fromValue(value: UByte): TLVType? {
@ -39,6 +41,9 @@ data class IdentityAnnouncement(
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
// Check size limits
val bridgeGeohashData = bridgeGeohash
?.toByteArray(Charsets.UTF_8)
?.takeIf { it.size in 1..12 }
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 ||
unknownTLVs.any { it.value.size > 255 }) {
return null
@ -68,6 +73,12 @@ data class IdentityAnnouncement(
result.addAll(capabilityBytes.toList())
}
bridgeGeohashData?.let { geohash ->
result.add(TLVType.BRIDGE_GEOHASH.value.toByte())
result.add(geohash.size.toByte())
result.addAll(geohash.toList())
}
// Preserve extensions this build does not understand. This includes
// gossip TLV 0x04 when an announcement is decoded through this model.
unknownTLVs.forEach { tlv ->
@ -92,6 +103,7 @@ data class IdentityAnnouncement(
var noisePublicKey: ByteArray? = null
var signingPublicKey: ByteArray? = null
var capabilities: PeerCapabilities? = null
var bridgeGeohash: String? = null
val unknownTLVs = mutableListOf<UnknownAnnouncementTLV>()
while (offset + 2 <= dataCopy.size) {
@ -125,6 +137,10 @@ data class IdentityAnnouncement(
TLVType.CAPABILITIES -> {
capabilities = PeerCapabilities.decode(value)
}
TLVType.BRIDGE_GEOHASH -> {
if (value.size !in 1..12) return null
bridgeGeohash = String(value, Charsets.UTF_8)
}
null -> {
// Retain unknown extensions so callers can forward or
// re-encode the announcement without erasing them.
@ -135,7 +151,14 @@ data class IdentityAnnouncement(
// All three fields are required
return if (nickname != null && noisePublicKey != null && signingPublicKey != null) {
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs)
IdentityAnnouncement(
nickname,
noisePublicKey,
signingPublicKey,
capabilities,
unknownTLVs,
bridgeGeohash
)
} else {
null
}
@ -145,12 +168,14 @@ data class IdentityAnnouncement(
fun forLocalPeer(
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray
signingPublicKey: ByteArray,
bridgeGeohash: String? = null
): IdentityAnnouncement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
capabilities = PeerCapabilities.LOCAL_SUPPORTED
capabilities = PeerCapabilities.localSupported(),
bridgeGeohash = bridgeGeohash
)
}
@ -166,6 +191,7 @@ data class IdentityAnnouncement(
if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false
if (capabilities != other.capabilities) return false
if (unknownTLVs != other.unknownTLVs) return false
if (bridgeGeohash != other.bridgeGeohash) return false
return true
}
@ -176,10 +202,11 @@ data class IdentityAnnouncement(
result = 31 * result + signingPublicKey.contentHashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + unknownTLVs.hashCode()
result = 31 * result + (bridgeGeohash?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})"
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue}, bridgeGeohash=$bridgeGeohash)"
}
}

View File

@ -0,0 +1,101 @@
package com.bitchat.android.model
import com.bitchat.android.nostr.NostrEvent
/**
* Wire payload for MessageType.NOSTR_CARRIER (0x28).
*
* The TLV layout and limits intentionally match iOS. Lengths are unsigned
* 16-bit big-endian values and unknown TLVs are skipped.
*/
data class NostrCarrierPacket(
val direction: Direction,
val geohash: String,
val eventJson: ByteArray
) {
enum class Direction(val value: Int) {
TO_GATEWAY(0x01),
FROM_GATEWAY(0x02),
TO_BRIDGE(0x03),
FROM_BRIDGE(0x04);
companion object {
fun fromValue(value: Int): Direction? = entries.firstOrNull { it.value == value }
}
}
init {
require(geohash.toByteArray(Charsets.UTF_8).size in 1..MAX_GEOHASH_LENGTH)
require(eventJson.size in 1..MAX_EVENT_JSON_BYTES)
}
fun event(): NostrEvent? =
NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8))
fun encode(): ByteArray {
return checkNotNull(
Tlv16Codec.encode(
Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())),
Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)),
Tlv16Codec.Field(TLV_EVENT_JSON, eventJson)
)
)
}
companion object {
const val MAX_EVENT_JSON_BYTES = 16 * 1024
const val MAX_GEOHASH_LENGTH = 12
private const val TLV_DIRECTION = 0x01
private const val TLV_GEOHASH = 0x02
private const val TLV_EVENT_JSON = 0x03
fun fromEvent(direction: Direction, geohash: String, event: NostrEvent): NostrCarrierPacket? =
runCatching {
NostrCarrierPacket(
direction = direction,
geohash = geohash,
eventJson = event.toJsonString().toByteArray(Charsets.UTF_8)
)
}.getOrNull()
fun decode(data: ByteArray): NostrCarrierPacket? {
var direction: Direction? = null
var geohash: String? = null
var eventJson: ByteArray? = null
Tlv16Codec.decode(data)?.forEach { field ->
when (field.type) {
TLV_DIRECTION -> {
if (field.value.size != 1) return null
direction =
Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null
}
TLV_GEOHASH -> {
geohash = field.value.toString(Charsets.UTF_8)
}
TLV_EVENT_JSON -> eventJson = field.value
}
} ?: return null
return runCatching {
NostrCarrierPacket(
direction = direction ?: return null,
geohash = geohash ?: return null,
eventJson = eventJson ?: return null
)
}.getOrNull()
}
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is NostrCarrierPacket &&
direction == other.direction &&
geohash == other.geohash &&
eventJson.contentEquals(other.eventJson))
override fun hashCode(): Int =
31 * (31 * direction.hashCode() + geohash.hashCode()) + eventJson.contentHashCode()
}

View File

@ -31,13 +31,11 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
private const val PRIVATE_MEDIA_BIT_INDEX = 8
val NONE = PeerCapabilities(0)
val PREKEYS = PeerCapabilities(1L shl 0)
val WIFI_BULK = PeerCapabilities(1L shl 1)
val GATEWAY = PeerCapabilities(1L shl 2)
val GROUPS = PeerCapabilities(1L shl 3)
val BOARD = PeerCapabilities(1L shl 4)
val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6)
val BRIDGE = PeerCapabilities(1L shl 7)
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
val PRIVATE_MEDIA = PeerCapabilities(1L shl PRIVATE_MEDIA_BIT_INDEX)
@ -50,8 +48,28 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
/** Reserved by iOS; decode it but do not advertise or act on it. */
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
/** Can bridge public mesh traffic through geohash rendezvous relays. */
val BRIDGE = PeerCapabilities(1L shl 7)
/** Publishes signed one-time prekeys for forward-secret courier mail. */
val PREKEYS = PeerCapabilities(1L shl 0)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue)
@Deprecated("Use localSupported() so runtime bridge state is included")
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue)
@Volatile
private var bridgeEnabled: Boolean = false
fun setBridgeEnabled(enabled: Boolean) {
bridgeEnabled = enabled
}
fun localSupported(): PeerCapabilities = PeerCapabilities(
LOCAL_SUPPORTED.rawValue or
PREKEYS.rawValue or
if (bridgeEnabled) BRIDGE.rawValue else 0L
)
/**
* Decode the low 64 bits and ignore any future extension bytes, which

View File

@ -0,0 +1,171 @@
package com.bitchat.android.model
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Signed batch of one-time Curve25519 keys carried by MessageType.PREKEY_BUNDLE (0x24).
*
* The canonical signing bytes and TLV representation intentionally match the
* iOS BitFoundation implementation byte-for-byte.
*/
data class PrekeyBundle(
val noiseStaticPublicKey: ByteArray,
val prekeys: List<Prekey>,
val generatedAt: Long,
val signature: ByteArray
) {
data class Prekey(val id: Long, val publicKey: ByteArray) {
init {
require(id in 0..0xFFFF_FFFFL)
require(publicKey.size == KEY_LENGTH)
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey))
override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode()
}
fun signableBytes(): ByteArray {
val output = ByteArrayOutputStream(
1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES
)
output.write(SIGNING_CONTEXT.size)
output.write(SIGNING_CONTEXT)
output.write(fixedKey(noiseStaticPublicKey))
output.write(prekeys.size.coerceAtMost(0xFF))
prekeys.take(0xFF).forEach { prekey ->
output.write(uint32Bytes(prekey.id))
output.write(fixedKey(prekey.publicKey))
}
output.write(uint64Bytes(generatedAt))
return output.toByteArray()
}
fun encode(): ByteArray? {
if (noiseStaticPublicKey.size != KEY_LENGTH ||
signature.size != SIGNATURE_LENGTH ||
prekeys.isEmpty() ||
prekeys.size > MAX_PREKEYS ||
prekeys.map { it.id }.distinct().size != prekeys.size
) {
return null
}
val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH)
prekeys.forEach { prekey ->
if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null
entries.write(uint32Bytes(prekey.id))
entries.write(prekey.publicKey)
}
return Tlv16Codec.encode(
Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey),
Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()),
Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)),
Tlv16Codec.Field(TLV_SIGNATURE, signature)
)
}
companion object {
const val KEY_LENGTH = 32
const val SIGNATURE_LENGTH = 64
const val MAX_PREKEYS = 8
private const val PREKEY_ENTRY_LENGTH = 4 + KEY_LENGTH
private val SIGNING_CONTEXT = "bitchat-prekey-bundle-v1".toByteArray(Charsets.UTF_8)
private const val TLV_NOISE_STATIC_KEY = 0x01
private const val TLV_PREKEYS = 0x02
private const val TLV_GENERATED_AT = 0x03
private const val TLV_SIGNATURE = 0x04
fun decode(data: ByteArray): PrekeyBundle? {
var noiseStaticKey: ByteArray? = null
var prekeys: List<Prekey>? = null
var generatedAt: Long? = null
var signature: ByteArray? = null
Tlv16Codec.decode(data)?.forEach { field ->
when (field.type) {
TLV_NOISE_STATIC_KEY -> {
if (field.value.size != KEY_LENGTH) return null
noiseStaticKey = field.value
}
TLV_PREKEYS -> {
if (field.value.isEmpty() ||
field.value.size % PREKEY_ENTRY_LENGTH != 0 ||
field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS
) {
return null
}
val parsed = mutableListOf<Prekey>()
var entryOffset = 0
while (entryOffset < field.value.size) {
val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.int.toLong() and 0xFFFF_FFFFL
entryOffset += Int.SIZE_BYTES
val publicKey =
field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH)
entryOffset += KEY_LENGTH
parsed += Prekey(id, publicKey)
}
if (parsed.map { it.id }.distinct().size != parsed.size) return null
prekeys = parsed
}
TLV_GENERATED_AT -> {
if (field.value.size != Long.SIZE_BYTES) return null
generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long
}
TLV_SIGNATURE -> {
if (field.value.size != SIGNATURE_LENGTH) return null
signature = field.value
}
}
} ?: return null
return runCatching {
PrekeyBundle(
noiseStaticPublicKey = noiseStaticKey ?: return null,
prekeys = prekeys?.takeIf { it.isNotEmpty() } ?: return null,
generatedAt = generatedAt ?: return null,
signature = signature ?: return null
)
}.getOrNull()
}
private fun uint32Bytes(value: Long): ByteArray =
ByteBuffer.allocate(Int.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putInt(value.toInt())
.array()
private fun uint64Bytes(value: Long): ByteArray =
ByteBuffer.allocate(Long.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putLong(value)
.array()
private fun fixedKey(key: ByteArray): ByteArray =
key.copyOf(KEY_LENGTH)
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is PrekeyBundle &&
noiseStaticPublicKey.contentEquals(other.noiseStaticPublicKey) &&
prekeys == other.prekeys &&
generatedAt == other.generatedAt &&
signature.contentEquals(other.signature))
override fun hashCode(): Int {
var result = noiseStaticPublicKey.contentHashCode()
result = 31 * result + prekeys.hashCode()
result = 31 * result + generatedAt.hashCode()
result = 31 * result + signature.contentHashCode()
return result
}
}

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,121 @@
package com.bitchat.android.noise
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
import com.bitchat.android.noise.southernstorm.protocol.Noise
/**
* One-message Noise X helper used by iOS-compatible courier envelopes.
*
* Protocol: Noise_X_25519_ChaChaPoly_SHA256
* Prologue: "bitchat-courier-v1"
*/
object CourierNoiseCrypto {
private const val PROTOCOL_NAME = "Noise_X_25519_ChaChaPoly_SHA256"
private val COURIER_PROLOGUE = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
private val PREKEY_PROLOGUE_PREFIX = "bitchat-prekey-v1".toByteArray(Charsets.UTF_8)
private const val X_OVERHEAD_BYTES = 32 + 48 + 16
data class Opened(val payload: ByteArray, val senderStaticKey: ByteArray)
fun seal(
payload: ByteArray,
senderStaticPrivateKey: ByteArray,
recipientStaticPublicKey: ByteArray
): ByteArray = sealWithPrologue(
payload,
senderStaticPrivateKey,
recipientStaticPublicKey,
COURIER_PROLOGUE
)
fun sealToPrekey(
payload: ByteArray,
senderStaticPrivateKey: ByteArray,
recipientPrekey: com.bitchat.android.model.PrekeyBundle.Prekey
): ByteArray = sealWithPrologue(
payload,
senderStaticPrivateKey,
recipientPrekey.publicKey,
prekeyPrologue(recipientPrekey.id)
)
private fun sealWithPrologue(
payload: ByteArray,
senderStaticPrivateKey: ByteArray,
recipientStaticPublicKey: ByteArray,
prologue: ByteArray
): ByteArray {
require(senderStaticPrivateKey.size == 32)
require(recipientStaticPublicKey.size == 32)
val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.INITIATOR)
return try {
handshake.setPrologue(prologue, 0, prologue.size)
handshake.getLocalKeyPair().setPrivateKey(senderStaticPrivateKey, 0)
handshake.getRemotePublicKey().setPublicKey(recipientStaticPublicKey, 0)
handshake.start()
val message = ByteArray(payload.size + X_OVERHEAD_BYTES)
val length = handshake.writeMessage(message, 0, payload, 0, payload.size)
message.copyOf(length)
} finally {
handshake.destroy()
}
}
fun open(
ciphertext: ByteArray,
recipientStaticPrivateKey: ByteArray
): Opened = openWithPrologue(ciphertext, recipientStaticPrivateKey, COURIER_PROLOGUE)
fun openWithPrekey(
ciphertext: ByteArray,
recipientPrekeyPrivateKey: ByteArray,
prekeyId: Long
): Opened = openWithPrologue(
ciphertext,
recipientPrekeyPrivateKey,
prekeyPrologue(prekeyId)
)
private fun openWithPrologue(
ciphertext: ByteArray,
recipientStaticPrivateKey: ByteArray,
prologue: ByteArray
): Opened {
require(recipientStaticPrivateKey.size == 32)
val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.RESPONDER)
return try {
handshake.setPrologue(prologue, 0, prologue.size)
handshake.getLocalKeyPair().setPrivateKey(recipientStaticPrivateKey, 0)
handshake.start()
val payload = ByteArray(ciphertext.size)
val length = handshake.readMessage(ciphertext, 0, ciphertext.size, payload, 0)
val senderKey = ByteArray(handshake.getRemotePublicKey().publicKeyLength)
handshake.getRemotePublicKey().getPublicKey(senderKey, 0)
Opened(payload.copyOf(length), senderKey)
} finally {
handshake.destroy()
}
}
/** Test/support helper that derives the X25519 public key used on the wire. */
fun publicKey(privateKey: ByteArray): ByteArray {
require(privateKey.size == 32)
val key = Noise.createDH("25519")
return try {
key.setPrivateKey(privateKey, 0)
ByteArray(key.publicKeyLength).also { key.getPublicKey(it, 0) }
} finally {
key.destroy()
}
}
private fun prekeyPrologue(prekeyId: Long): ByteArray {
require(prekeyId in 0..0xFFFF_FFFFL)
return PREKEY_PROLOGUE_PREFIX + byteArrayOf(
(prekeyId ushr 24).toByte(),
(prekeyId ushr 16).toByte(),
(prekeyId ushr 8).toByte(),
prekeyId.toByte()
)
}
}

View File

@ -43,6 +43,9 @@ internal interface BridgeCourierCipher {
fun staticPublicKey(): ByteArray?
fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray
fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> =
seal(payload, recipientNoiseKey) to null
}
private class NostrBridgeCourierRelay(
@ -77,12 +80,20 @@ private class NostrBridgeCourierRelay(
}
private class EncryptionBridgeCourierCipher(
private val encryptionService: EncryptionService
private val encryptionService: EncryptionService,
private val prekeys: com.bitchat.android.services.bridge.PrekeyManager
) : BridgeCourierCipher {
override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
encryptionService.sealCourierPayload(payload, recipientNoiseKey)
override fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> {
val id = java.security.MessageDigest.getInstance("SHA-256").digest(payload)
.joinToString("") { "%02x".format(it) }
val sealed = prekeys.seal(payload, id, recipientNoiseKey, recipientAdvertisesPrekeys = true)
return sealed.ciphertext to sealed.prekeyId?.toUInt()
}
}
/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */
@ -92,18 +103,20 @@ class BridgeCourierService internal constructor(
private val relayManager: BridgeCourierRelay,
private val relayUrls: List<String> = NostrRelayManager.defaultRelays(),
private val clock: () -> Long = System::currentTimeMillis,
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate,
private val enabled: () -> Boolean = { true }
) {
constructor(
context: Context,
encryptionService: EncryptionService,
onEnvelope: (CourierEnvelope) -> Unit
) : this(
cipher = EncryptionBridgeCourierCipher(encryptionService),
cipher = EncryptionBridgeCourierCipher(encryptionService, com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)),
onEnvelope = onEnvelope,
relayManager = NostrBridgeCourierRelay(
NostrRelayManager.getInstance(context.applicationContext)
)
),
enabled = { com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.value }
)
companion object {
@ -125,6 +138,7 @@ class BridgeCourierService internal constructor(
@Synchronized
fun start() {
if (!enabled()) return
val localKey = cipher.staticPublicKey() ?: ByteArray(0)
if (localKey.size == 32) {
val now = clock()
@ -177,14 +191,16 @@ class BridgeCourierService internal constructor(
recipientNoiseKey: ByteArray,
onAccepted: () -> Unit = {}
): Boolean {
if (!enabled()) return false
start()
if (!relayManager.hasConnectedRelay(relayUrls)) return false
val sealed = try { cipher.seal(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
val sealed = try { cipher.sealWithPrekey(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
val now = clock()
val envelope = CourierEnvelope(
recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)),
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = sealed,
ciphertext = sealed.first,
prekeyID = sealed.second,
copies = 1u
)
val encoded = envelope.encode() ?: return false
@ -202,6 +218,7 @@ class BridgeCourierService internal constructor(
content = Base64.encodeToString(encoded, Base64.NO_WRAP)
)
)
if (!enabled()) return false
return relayManager.sendEvent(event, relayUrls, onAccepted)
}
@ -214,6 +231,7 @@ class BridgeCourierService internal constructor(
}
private fun handleEvent(event: NostrEvent) {
if (!enabled()) return
if (event.kind != KIND || !event.isValidSignature()) return
synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return }
if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return

View File

@ -0,0 +1,14 @@
package com.bitchat.android.nostr
import java.security.MessageDigest
/** Cross-platform stable identity for a public mesh radio/bridge copy. */
object MeshMessageIdentity {
fun stableId(senderIdHex: String, timestampMs: Long, content: String): String {
val input = "${senderIdHex.lowercase()}|$timestampMs|${content.trim()}"
return MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
.take(32)
}
}

View File

@ -69,6 +69,28 @@ data class NostrFilter(
limit = limit
)
}
fun bridgeRendezvous(
cells: Collection<String>,
since: Long? = null,
limit: Int = 200
): NostrFilter = NostrFilter(
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("r" to cells.toList()),
limit = limit
)
fun courierDrops(
recipientTagsHex: Collection<String>,
since: Long? = null,
limit: Int = 100
): NostrFilter = NostrFilter(
kinds = listOf(NostrKind.COURIER_DROP),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("x" to recipientTagsHex.toList()),
limit = limit
)
/**
* Create filter for text notes from specific authors

View File

@ -176,6 +176,39 @@ object NostrIdentityBridge {
Log.d(TAG, "Used fallback geohash identity derivation")
return fallbackIdentity
}
/**
* Derive the iOS-compatible bridge rendezvous identity. The domain label
* prevents linking it to geohash-chat identity and the iteration is
* encoded big-endian, matching CryptoKit's UInt32.bigEndian bytes.
*/
fun deriveBridgeIdentity(cell: String, context: Context): NostrIdentity {
val label = "bridge|$cell"
geohashIdentityCache[label]?.let { return it }
val stateManager = SecureIdentityStateManager(context)
val seed = getOrCreateDeviceSeed(stateManager)
val message = label.toByteArray(Charsets.UTF_8)
for (iteration in 0 until 10) {
val input = message + byteArrayOf(
(iteration ushr 24).toByte(),
(iteration ushr 16).toByte(),
(iteration ushr 8).toByte(),
iteration.toByte()
)
val candidate = hmacSha256(seed, input).toHexStringLocal()
if (NostrCrypto.isValidPrivateKey(candidate)) {
return NostrIdentity.fromPrivateKey(candidate).also {
geohashIdentityCache[label] = it
}
}
}
val fallback = MessageDigest.getInstance("SHA-256").digest(seed + message)
return NostrIdentity.fromPrivateKey(fallback.toHexStringLocal()).also {
geohashIdentityCache[label] = it
}
}
/**
* Generate candidate key for a specific iteration (matches iOS implementation)

View File

@ -65,6 +65,12 @@ object NostrProtocol {
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
return try {
val recipientTags = listOf(listOf("p", recipientIdentity.publicKeyHex))
if (giftWrap.content.toByteArray(Charsets.UTF_8).size > 64 * 1024 ||
giftWrap.kind != NostrKind.GIFT_WRAP || giftWrap.tags != recipientTags ||
!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt) ||
!giftWrap.isValidSignature()
) return null
// 1. Unwrap the gift wrap
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
?: run {
@ -74,7 +80,7 @@ object NostrProtocol {
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) {
if (seal.kind != NostrKind.SEAL || seal.tags.isNotEmpty() || !seal.isValidSignature()) {
Log.w(TAG, "❌ Invalid NIP-17 seal signature")
return null
}
@ -86,7 +92,10 @@ object NostrProtocol {
return null
}
if (seal.pubkey != rumor.pubkey) {
if (seal.pubkey != rumor.pubkey || rumor.kind != NostrKind.DIRECT_MESSAGE ||
(rumor.tags.isNotEmpty() && rumor.tags != recipientTags) || rumor.sig != null ||
!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumor.createdAt)
) {
Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey")
return null
}
@ -235,6 +244,73 @@ object NostrProtocol {
return@withContext senderIdentity.signEvent(event)
}
/** iOS-compatible public mesh event on a bridge rendezvous cell. */
fun createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = null,
meshSenderId: String? = null,
meshTimestampMs: Long? = null
): NostrEvent {
val tags = mutableListOf<List<String>>(listOf("r", cell))
nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) }
val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() }
if (sender != null && meshTimestampMs != null) {
tags += listOf(
"m",
MeshMessageIdentity.stableId(sender, meshTimestampMs, content),
sender,
meshTimestampMs.toString()
)
}
return senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = tags,
content = content
)
)
}
/** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */
fun createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
): NostrEvent = senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.GEOHASH_PRESENCE,
tags = listOf(listOf("r", cell)),
content = ""
)
)
/**
* Opaque relay drop. Callers use a throwaway identity so deposits cannot
* be linked by their Nostr publisher key.
*/
fun createCourierDropEvent(
envelope: ByteArray,
recipientTagHex: String,
expiresAtMs: Long,
senderIdentity: NostrIdentity
): NostrEvent = senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.COURIER_DROP,
tags = listOf(
listOf("x", recipientTagHex),
listOf("expiration", (expiresAtMs / 1000).toString())
),
content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP)
)
)
// MARK: - Private Methods

View File

@ -22,7 +22,9 @@ enum class MessageType(val value: UByte) {
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync
BOARD_POST(0x23u),
GROUP_MESSAGE(0x25u); // Opaque private-group ciphertext broadcast
GROUP_MESSAGE(0x25u), // Opaque private-group ciphertext broadcast
PREKEY_BUNDLE(0x24u), // Signed batch of one-time courier prekeys
NOSTR_CARRIER(0x28u); // Signed bridge/gateway event carrier
companion object {
fun fromValue(value: UByte): MessageType? {

View File

@ -188,6 +188,11 @@ object AppStateStore {
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (!msg.isBridged) {
_publicMessages.value = _publicMessages.value.filterNot {
it.isBridged && it.bridgeRadioMessageIdHint == msg.id
}
}
val publicKey = publicMessageKey(msg)
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
seenMessageIds.add(msg.id)
@ -196,6 +201,10 @@ object AppStateStore {
}
}
fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) {
_publicMessages.value.any { !it.isBridged && it.id == messageId }
}
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
fun upsertPublicMessage(msg: BitchatMessage) {
synchronized(this) {

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,100 @@
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()
}
/**
* Owns one logical relay subscription while assigning a distinct wire ID to
* every replacement. Relay CLOSE/REQ writes may execute out of order, but a
* delayed close can only affect the retired generation.
*
* Callers keep this object confined to their coordinator dispatcher.
*/
internal class RelaySubscriptionSlot(private val idPrefix: String) {
private var generation = 0L
private var activeId: String? = null
fun replace(close: (String) -> Unit, open: (String) -> Unit): String {
activeId?.let(close)
val replacementId = "$idPrefix-${++generation}"
activeId = replacementId
try {
open(replacementId)
} catch (error: Throwable) {
activeId = null
throw error
}
return replacementId
}
fun close(close: (String) -> Unit) {
val id = activeId ?: return
activeId = null
close(id)
}
}
/**
* 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,44 @@
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 isVerifiedNickname: Boolean,
val capabilities: PeerCapabilities?,
val bridgeCell: String?,
val lastSeenMs: Long
)

View File

@ -0,0 +1,40 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.protocol.BitchatPacket
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
/**
* Verifies bridge protocol packets against the signing key bound by the
* sender's authenticated announcement.
*/
internal object BridgePacketSignatureVerifier {
fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean {
val signature = packet.signature?.takeIf { it.size == 64 } ?: return false
val signingData = packet.toBinaryDataForSigning() ?: return false
if (publicKey.size != 32) return false
return runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(signingData, 0, signingData.size)
}.verifySignature(signature)
}.getOrDefault(false)
}
}
internal object CourierDepositAuthenticator {
fun authenticate(
packet: BitchatPacket,
fromPeerId: String,
directIngress: Boolean,
peers: List<VerifiedBridgePeer>,
isTrusted: (VerifiedBridgePeer) -> Boolean
): VerifiedBridgePeer? {
if (!directIngress || packet.senderID.toHex() != fromPeerId) return null
val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null
return peer.takeIf(isTrusted)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

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,772 @@
package com.bitchat.android.services.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.core.content.edit
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.BridgeMeshDelegate
import com.bitchat.android.mesh.BridgeOutboundPolicy
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NostrCarrierPacket
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.nostr.NostrEvent
import com.bitchat.android.nostr.NostrFilter
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrKind
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.ui.DataManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Date
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.abs
/**
* Opt-in bridge policy shared by foreground transport and Compose UI.
*
* Outbound public traffic crosses the bridge only when the author opted in
* and did not mark the message nearby-only. Passive `fromBridge` reception is
* accepted regardless of the switch because it exposes no local traffic.
*/
object MeshBridgeService : BridgeMeshDelegate {
private data class PendingUplink(
val depositor: String,
val cell: String,
val event: NostrEvent
)
private data class PendingDownlink(
val cell: String,
val event: NostrEvent
)
private const val TAG = "MeshBridgeService"
private const val PREFS = "bitchat_bridge"
private const val KEY_ENABLED = "bridge_enabled_v1"
private const val CELL_PRECISION = 6
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
private const val MAX_CONTENT_BYTES = 16_000
private const val MAX_TRACKED_IDS = 512
private const val MAX_PARTICIPANTS = 128
private const val PARTICIPANT_FRESH_MS = 10L * 60 * 1000
private const val PRESENCE_INTERVAL_MS = 4L * 60 * 1000
private const val MAX_QUEUED_UPLINKS = 20
private const val MAX_UPLINKS_PER_DEPOSITOR = 5
private const val UPLINKS_PER_MINUTE_PER_DEPOSITOR = 10
private const val MAX_PENDING_DOWNLINKS = 30
private const val DOWNLINKS_PER_MINUTE = 20
private const val INBOUND_PER_MINUTE = 600
private const val INBOUND_PER_SIGNER_PER_MINUTE = 120
private const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720
private val dispatcher = Dispatchers.Default.limitedParallelism(1)
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _isEnabled = MutableStateFlow(false)
val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow()
private val _nearbyOnly = MutableStateFlow(false)
val nearbyOnly: StateFlow<Boolean> = _nearbyOnly.asStateFlow()
private val _activeCell = MutableStateFlow<String?>(null)
val activeCell: StateFlow<String?> = _activeCell.asStateFlow()
private val _bridgedParticipants = MutableStateFlow<List<BridgedParticipant>>(emptyList())
val bridgedParticipants: StateFlow<List<BridgedParticipant>> = _bridgedParticipants.asStateFlow()
private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied)
private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous")
@Volatile
private var appContext: Context? = null
private var relayManager: NostrRelayManager? = null
private var prekeyCoordinator: PrekeyCoordinator? = null
private var prefs: android.content.SharedPreferences? = null
@Volatile
private var meshProvider: () -> MeshService? = { null }
private var clock: () -> Long = System::currentTimeMillis
private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
private var localLocationCell: String? = null
private var subscribedCells: Set<String> = emptySet()
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)
private val rebroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val injectedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS)
private val queuedUplinks = mutableListOf<PendingUplink>()
private val pendingDownlinks = mutableListOf<PendingDownlink>()
private val participants = linkedMapOf<String, BridgedParticipant>()
private val uplinkTimes = mutableMapOf<String, MutableList<Long>>()
private val inboundTimes = mutableListOf<Long>()
private val inboundTimesBySigner = mutableMapOf<String, MutableList<Long>>()
private val downlinkTimes = mutableListOf<Long>()
private val signatureAttemptTimes = mutableListOf<Long>()
private var downlinkJob: Job? = null
private var presenceJob: Job? = null
@Volatile
private var suppressPrekeyBroadcasts = false
@Volatile
private var panicQuiesced = false
fun initialize(
context: Context,
meshProvider: () -> MeshService? = {
com.bitchat.android.service.MeshServiceHolder.unifiedMeshService
},
clock: () -> Long = System::currentTimeMillis,
jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
) {
if (appContext != null) return
synchronized(this) {
if (appContext != null) return
val application = context.applicationContext
appContext = application
this.meshProvider = meshProvider
this.clock = clock
this.jitter = jitter
relayManager = NostrRelayManager.getInstance(application)
val prekeyManager = PrekeyManager.getInstance(application)
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val storedEnabled = loadEnabledWithMigration(prefs!!)
_isEnabled.value = storedEnabled && !panicQuiesced
if (panicQuiesced && storedEnabled) {
prefs?.edit { putBoolean(KEY_ENABLED, false) }
}
outboundPolicy.set(
BridgeOutboundPolicy.capture(
enabled = _isEnabled.value,
nearbyOnly = _nearbyOnly.value
)
)
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
prekeyCoordinator = PrekeyCoordinator(
manager = prekeyManager,
meshProvider = ::currentMesh,
peersProvider = verifiedPeerSnapshot::get,
clock = clock
)
}
val location = LocationChannelManager.getInstance(context)
scope.launch {
location.availableChannels.collect { channels ->
if (!_isEnabled.value) {
localLocationCell = null
return@collect
}
localLocationCell = channels
.firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD }
?.geohash
?.take(CELL_PRECISION)
refreshRendezvous()
}
}
scope.launch {
relayManager?.isConnected?.collect { connected ->
if (connected) {
refreshRendezvous(forceSubscriptions = true)
flushQueuedUplinks()
publishPresence()
}
}
}
scope.launch {
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 ->
}
}
scope.launch {
if (_isEnabled.value) {
relayManager?.connect()
location.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous(forceSubscriptions = true)
}
if (_isEnabled.value) startPresenceLoop()
delay(2_000)
broadcastPrekeys(force = true)
}
}
fun setEnabled(enabled: Boolean) {
if (outboundPolicy.get().enabled == enabled) return
if (enabled) {
panicQuiesced = false
suppressPrekeyBroadcasts = false
}
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
_isEnabled.value = enabled
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
PeerCapabilities.setBridgeEnabled(enabled)
_nearbyOnly.value = false
scope.launch {
if (!enabled) {
stopPresenceLoop()
closeSubscriptions()
queuedUplinks.clear()
pendingDownlinks.clear()
participants.clear()
publishParticipants()
localLocationCell = null
_activeCell.value = null
} else {
startPresenceLoop()
relayManager?.connect()
LocationChannelManager.getInstance(requireContext())
.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous(forceSubscriptions = true)
broadcastPrekeys(force = true)
}
currentMesh()?.sendBroadcastAnnounce()
}
}
fun setNearbyOnly(enabled: Boolean) {
outboundPolicy.updateAndGet { current ->
BridgeOutboundPolicy.capture(current.enabled, nearbyOnly = enabled)
}
_nearbyOnly.value = enabled
}
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
override fun outboundPolicy(): BridgeOutboundPolicy = outboundPolicy.get()
override fun bridgeOutgoing(
content: String,
senderPeerId: String,
timestampMs: Long,
nickname: String?,
policyAtSend: BridgeOutboundPolicy
) {
scope.launch {
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
val cell = _activeCell.value ?: currentCell() ?: return@launch
if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
val event = NostrProtocol.createBridgeMeshEvent(
content = content,
cell = cell,
senderIdentity = identity,
nickname = nickname,
meshSenderId = senderPeerId,
meshTimestampMs = timestampMs
)
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
publishedEventIds.add(event.id)
injectedEventIds.add(event.id)
if (relayManager?.isConnected?.value == true) {
relayManager?.sendEventToGeohash(event, cell)
} else {
val peer = availableBridgePeer() ?: return@launch
NostrCarrierPacket.fromEvent(
NostrCarrierPacket.Direction.TO_BRIDGE,
cell,
event
)?.encode()?.let { currentMesh()?.sendNostrCarrier(it, peer.peerId) }
}
}
}
/** Called only after a public radio packet's Ed25519 signature was accepted. */
override fun handleAuthenticatedRadioMessage(messageId: String) {
if (panicQuiesced || messageId.isBlank()) return
scope.launch {
radioMessageIds.add(messageId)
pendingDownlinks.removeAll { pending ->
classifyMessage(pending.event, pending.cell)?.bridgeRadioMessageIdHint == messageId
}
}
}
override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
if (panicQuiesced) return
scope.launch {
val peer = VerifiedBridgePeer(
peerId = peerId,
nickname = announcement.nickname,
noiseKey = announcement.noisePublicKey.copyOf(),
signingKey = announcement.signingPublicKey.copyOf(),
isVerifiedNickname =
currentMesh()?.getPeerInfo(peerId)?.isVerifiedNickname == true,
capabilities = announcement.capabilities,
bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash),
lastSeenMs = clock()
)
verifiedPeers[peerId] = peer
while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first())
publishVerifiedPeerSnapshot()
prekeyCoordinator?.handlePeerVerified(peerId)
if (_isEnabled.value) {
refreshRendezvous()
}
broadcastPrekeys()
}
}
override fun handlePrekeyPacket(packet: BitchatPacket) {
if (panicQuiesced) return
scope.launch { prekeyCoordinator?.handlePacket(packet) }
}
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
if (panicQuiesced) return
scope.launch {
val carrier = NostrCarrierPacket.decode(payload) ?: return@launch
when (carrier.direction) {
NostrCarrierPacket.Direction.TO_BRIDGE -> {
if (directedToUs) handleUplink(carrier, fromPeerId)
}
NostrCarrierPacket.Direction.FROM_BRIDGE -> {
if (!directedToUs) handleDownlink(carrier)
}
NostrCarrierPacket.Direction.TO_GATEWAY,
NostrCarrierPacket.Direction.FROM_GATEWAY -> Unit
}
}
}
@SuppressLint("ApplySharedPref", "UseKtx")
suspend fun wipe() {
// Revoke policy synchronously so no already-queued bridge work can be
// authorized by the pre-panic setting.
outboundPolicy.set(BridgeOutboundPolicy.Denied)
panicQuiesced = true
suppressPrekeyBroadcasts = true
_isEnabled.value = false
_nearbyOnly.value = false
PeerCapabilities.setBridgeEnabled(false)
kotlinx.coroutines.withContext(dispatcher) {
prefs?.edit()?.putBoolean(KEY_ENABLED, false)?.commit()
stopPresenceLoop()
downlinkJob?.cancel()
downlinkJob = null
closeSubscriptions()
queuedUplinks.clear()
pendingDownlinks.clear()
localLocationCell = null
_activeCell.value = null
verifiedPeers.clear()
publishVerifiedPeerSnapshot()
participants.clear()
publishedEventIds.clear()
receivedEventIds.clear()
meshBroadcastEventIds.clear()
rebroadcastEventIds.clear()
injectedEventIds.clear()
radioMessageIds.clear()
uplinkTimes.clear()
inboundTimes.clear()
inboundTimesBySigner.clear()
downlinkTimes.clear()
signatureAttemptTimes.clear()
prekeyCoordinator?.wipe()
publishParticipants()
}
}
private fun publishVerifiedPeerSnapshot() {
verifiedPeerSnapshot.set(
verifiedPeers.values.map { peer ->
peer.copy(
noiseKey = peer.noiseKey.copyOf(),
signingKey = peer.signingKey.copyOf()
)
}
)
}
private suspend fun refreshRendezvous(forceSubscriptions: Boolean = false) {
if (!_isEnabled.value) return
val cell = currentCell()
val changed = cell != _activeCell.value
if (changed) {
_activeCell.value = cell
currentMesh()?.sendBroadcastAnnounce()
}
if (cell == null) return
val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) }
if (changed || forceSubscriptions || cells != subscribedCells) {
val targets = linkedSetOf<String>()
cells.forEach { subscribedCell ->
relayManager?.ensureGeohashRelaysConnected(subscribedCell)
targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty()
}
relayManager?.let { manager ->
rendezvousSubscription.replace(
close = manager::unsubscribe,
open = { subscriptionId ->
manager.subscribe(
filter = NostrFilter.bridgeRendezvous(
cells,
since = clock() - MAX_EVENT_AGE_MS
),
id = subscriptionId,
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
targetRelayUrls = targets.toList()
)
}
)
subscribedCells = cells
}
publishPresence()
}
}
private fun currentCell(): String? {
localLocationCell?.takeIf(::isValidGeohash)?.let { return it.take(CELL_PRECISION) }
return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION)
}
private fun availableBridgePeer(): VerifiedBridgePeer? =
verifiedPeers.values.firstOrNull { peer ->
peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true &&
peer.bridgeCell != null &&
currentMesh()?.getPeerInfo(peer.peerId)?.isConnected == true
}
private fun handleRendezvousEvent(event: NostrEvent) {
if (!_isEnabled.value) return
val cell = event.tagValue("r") ?: return
if (cell !in subscribedCells || publishedEventIds.contains(event.id)) return
if (isOwnEvent(event, cell)) {
publishedEventIds.add(event.id)
return
}
if (!allowSignatureAttempt() || !event.isValidSignature()) return
if (!receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
if (!isFresh(event) || event.tagValue("r") != cell || !isValidGeohash(cell)) return
when (event.kind) {
NostrKind.GEOHASH_PRESENCE -> recordParticipant(event.pubkey, null)
NostrKind.EPHEMERAL_EVENT -> {
val message = classifyMessage(event, cell) ?: return
val localRadio = message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true
if (!localRadio && injectBridgeMessage(message)) {
recordParticipant(event.pubkey, event.tagValue("n"))
}
if (!localRadio &&
!meshBroadcastEventIds.contains(event.id) &&
!rebroadcastEventIds.contains(event.id) &&
pendingDownlinks.none { it.event.id == event.id }
) {
pendingDownlinks += PendingDownlink(cell, event)
while (pendingDownlinks.size > MAX_PENDING_DOWNLINKS) pendingDownlinks.removeAt(0)
scheduleDownlink(jitter = true)
}
}
}
}
private fun handleUplink(carrier: NostrCarrierPacket, depositor: String) {
if (!_isEnabled.value || !allowUplink(depositor)) return
val event = structurallyValidEvent(carrier) ?: return
if (meshBroadcastEventIds.contains(event.id) ||
publishedEventIds.contains(event.id) ||
queuedUplinks.any { it.event.id == event.id }
) {
return
}
if (!allowSignatureAttempt() || !event.isValidSignature()) return
if (relayManager?.isConnected?.value == true) {
publishCarriedEvent(event, carrier.geohash)
} else {
if (queuedUplinks.count { it.depositor == depositor } >= MAX_UPLINKS_PER_DEPOSITOR) return
queuedUplinks += PendingUplink(depositor, carrier.geohash, event)
while (queuedUplinks.size > MAX_QUEUED_UPLINKS) queuedUplinks.removeAt(0)
}
}
private fun handleDownlink(carrier: NostrCarrierPacket) {
val event = structurallyValidEvent(carrier) ?: return
if (publishedEventIds.contains(event.id) || isOwnEvent(event, carrier.geohash)) return
if (!allowSignatureAttempt() || !event.isValidSignature()) return
val firstMesh = meshBroadcastEventIds.add(event.id)
if (!firstMesh || !receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
val message = classifyMessage(event, carrier.geohash) ?: return
if (injectBridgeMessage(message)) recordParticipant(event.pubkey, event.tagValue("n"))
}
private fun structurallyValidEvent(carrier: NostrCarrierPacket): NostrEvent? {
if (!isValidGeohash(carrier.geohash) ||
carrier.eventJson.size > NostrCarrierPacket.MAX_EVENT_JSON_BYTES
) {
return null
}
val event = carrier.event() ?: return null
if (!isFresh(event) || event.tagValue("r") != carrier.geohash) return null
return when (event.kind) {
NostrKind.GEOHASH_PRESENCE -> event
NostrKind.EPHEMERAL_EVENT ->
event.takeIf { classifyMessage(it, carrier.geohash) != null }
else -> null
}
}
private fun classifyMessage(event: NostrEvent, cell: String): BitchatMessage? {
if (event.kind != NostrKind.EPHEMERAL_EVENT ||
!isFresh(event) ||
event.tagValue("r") != cell ||
!isValidGeohash(cell)
) {
return null
}
val content = event.content
if (content.isBlank() || content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return null
val nickname = event.tagValue("n")?.trim()?.takeIf { it.isNotEmpty() }
val m = event.tags.firstOrNull { it.size >= 4 && it[0] == "m" }
val radioHint = if (m != null &&
m[2].matches(Regex("^[0-9a-fA-F]{16}$"))
) {
m[3].toLongOrNull()?.let { MeshMessageIdentity.stableId(m[2], it, content) }
} else {
null
}
return BitchatMessage(
id = event.id,
sender = "${nickname ?: "anon"}#${event.pubkey.takeLast(4)}",
content = content,
timestamp = Date(event.createdAt * 1000L),
senderPeerID = "bridge:${event.pubkey.take(16)}",
isBridged = true,
bridgeRadioMessageIdHint = radioHint
)
}
private fun injectBridgeMessage(message: BitchatMessage): Boolean {
if (!injectedEventIds.add(message.id)) return false
if (message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) return false
AppStateStore.addPublicMessage(message)
return true
}
private fun radioCopyPresent(messageId: String): Boolean =
radioMessageIds.contains(messageId) || AppStateStore.hasRadioPublicMessage(messageId)
private fun scheduleDownlink(jitter: Boolean) {
if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return
val now = clock()
downlinkTimes.removeAll { now - it >= 60_000 }
val waitMs = if (jitter) {
jitter(200, 1_501)
} else {
(downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50)
}
downlinkJob = scope.launch {
delay(waitMs)
drainDownlinks()
}
}
private fun drainDownlinks() {
val now = clock()
downlinkTimes.removeAll { now - it >= 60_000 }
while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) {
val item = pendingDownlinks.removeAt(0)
if (!isFresh(item.event) ||
meshBroadcastEventIds.contains(item.event.id) ||
rebroadcastEventIds.contains(item.event.id)
) {
continue
}
val message = classifyMessage(item.event, item.cell)
if (message?.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) continue
val payload = NostrCarrierPacket.fromEvent(
NostrCarrierPacket.Direction.FROM_BRIDGE,
item.cell,
item.event
)?.encode() ?: continue
currentMesh()?.sendNostrCarrier(payload)
rebroadcastEventIds.add(item.event.id)
downlinkTimes += clock()
}
if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false)
}
private fun flushQueuedUplinks() {
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
val queued = queuedUplinks.toList()
queuedUplinks.clear()
queued.filterNot { publishedEventIds.contains(it.event.id) }
.forEach { publishCarriedEvent(it.event, it.cell) }
}
private fun publishCarriedEvent(event: NostrEvent, cell: String) {
publishedEventIds.add(event.id)
relayManager?.sendEventToGeohash(event, cell)
}
private fun publishPresence() {
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
val cell = _activeCell.value ?: return
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
val event = NostrProtocol.createBridgePresenceEvent(cell, identity)
publishedEventIds.add(event.id)
relayManager?.sendEventToGeohash(event, cell)
}
private fun startPresenceLoop() {
if (presenceJob?.isActive == true) return
presenceJob = scope.launch {
while (true) {
delay(PRESENCE_INTERVAL_MS)
pruneParticipants()
if (_isEnabled.value) {
LocationChannelManager.getInstance(requireContext())
.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous()
publishPresence()
broadcastPrekeys()
}
}
}
}
private fun stopPresenceLoop() {
presenceJob?.cancel()
presenceJob = null
}
fun refreshPrekeys() {
scope.launch { broadcastPrekeys(force = true) }
}
private fun broadcastPrekeys(force: Boolean = false) {
if (suppressPrekeyBroadcasts) return
prekeyCoordinator?.broadcast(force)
}
private fun recordParticipant(pubkey: String, nickname: String?) {
val now = clock()
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
if (pubkey !in participants && participants.size >= MAX_PARTICIPANTS) {
participants.minByOrNull { it.value.lastSeenMs }?.key?.let(participants::remove)
}
val previous = participants[pubkey]
participants[pubkey] = BridgedParticipant(
pubkey,
nickname?.trim()?.takeIf { it.isNotEmpty() } ?: previous?.nickname,
now
)
publishParticipants()
}
private fun pruneParticipants() {
val now = clock()
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
publishParticipants()
}
private fun publishParticipants() {
_bridgedParticipants.value = participants.values.sortedByDescending { it.lastSeenMs }
}
private fun allowUplink(depositor: String): Boolean {
val now = clock()
val times = uplinkTimes.getOrPut(depositor) { mutableListOf() }
times.removeAll { now - it >= 60_000 }
if (times.size >= UPLINKS_PER_MINUTE_PER_DEPOSITOR) return false
times += now
return true
}
private fun allowInbound(signer: String): Boolean {
val now = clock()
inboundTimes.removeAll { now - it >= 60_000 }
if (inboundTimes.size >= INBOUND_PER_MINUTE) return false
val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() }
signerTimes.removeAll { now - it >= 60_000 }
if (signerTimes.size >= INBOUND_PER_SIGNER_PER_MINUTE) return false
inboundTimes += now
signerTimes += now
return true
}
private fun allowSignatureAttempt(): Boolean {
val now = clock()
signatureAttemptTimes.removeAll { now - it >= 60_000 }
if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false
signatureAttemptTimes += now
return true
}
private fun closeSubscriptions() {
relayManager?.let { manager ->
rendezvousSubscription.close(manager::unsubscribe)
}
subscribedCells = emptySet()
}
private fun isOwnEvent(event: NostrEvent, cell: String): Boolean =
runCatching {
NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
.publicKeyHex.equals(event.pubkey, ignoreCase = true)
}.getOrDefault(false)
private fun isFresh(event: NostrEvent): Boolean =
abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS
private fun isValidGeohash(value: String): Boolean =
value.length in 1..12 &&
value.matches(Regex("^[0123456789bcdefghjkmnpqrstuvwxyz]+$", RegexOption.IGNORE_CASE))
private fun NostrEvent.tagValue(name: String): String? =
tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
private fun currentMesh(): MeshService? = meshProvider()
private fun requireContext(): Context =
checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" }
private fun loadEnabledWithMigration(
preferences: android.content.SharedPreferences
): Boolean {
if (preferences.contains(KEY_ENABLED)) return preferences.getBoolean(KEY_ENABLED, false)
val legacyKeys = listOf("gateway_user_enabled", "gateway_enabled", "gateway.userEnabled")
val migrated = legacyKeys.any { preferences.getBoolean(it, false) }
preferences.edit {
putBoolean(KEY_ENABLED, migrated)
legacyKeys.forEach(::remove)
}
return migrated
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -0,0 +1,71 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.PrekeyBundle
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactIdentityResolver
/**
* Coordinates authenticated prekey packets without owning cryptographic
* persistence. [PrekeyManager] remains the repository/crypto boundary.
*/
internal class PrekeyCoordinator(
private val manager: PrekeyManager,
private val meshProvider: () -> MeshService?,
private val peersProvider: () -> List<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
) {
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return
if (manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())) {
com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager?.onPublicPacketSeen(packet)
}
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private companion object {
const val MAX_PENDING_PACKETS = 64
const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000
}
}

View File

@ -0,0 +1,331 @@
package com.bitchat.android.services.bridge
import android.content.Context
import android.util.Base64
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.PrekeyBundle
import com.bitchat.android.noise.CourierNoiseCrypto
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.SecureRandom
/**
* Owns local one-time prekeys and verified peer bundles for courier v2.
*
* Local private keys use EncryptedSharedPreferences through
* [SecureIdentityStateManager]. Peer bundles contain public material only,
* but their consumption assignments are persisted so retries of one message
* never spend additional prekeys.
*/
class PrekeyManager internal constructor(
private val identity: PrekeyIdentity,
private val localStore: LocalPrekeyStore,
private val peerStore: PeerPrekeyStore,
private val randomBytes: () -> ByteArray
) {
data class Sealed(
val ciphertext: ByteArray,
val prekeyId: Long?
)
data class Opened(
val payload: ByteArray,
val senderStaticKey: ByteArray,
val consumedPrekey: Boolean
)
private val lock = Any()
private var local: LocalPrekeyState? = null
private var peerBundles: MutableMap<String, StoredPeerPrekeyBundle>? = null
fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) {
val staticKey = identity.staticKey()?.second ?: return@synchronized null
val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null
val state = loadLocalLocked()
replenishLocked(state, nowMs)
val prekeys = state.records
.asSequence()
.filter { it.consumedAt == null }
.sortedBy { it.id }
.mapNotNull { record ->
decode(record.privateKey)?.let { privateKey ->
PrekeyBundle.Prekey(record.id, CourierNoiseCrypto.publicKey(privateKey))
}
}
.toList()
if (prekeys.isEmpty()) return@synchronized null
val unsigned = PrekeyBundle(
noiseStaticPublicKey = staticKey,
prekeys = prekeys,
generatedAt = state.generatedAt,
signature = ByteArray(PrekeyBundle.SIGNATURE_LENGTH)
)
val signature = signEd25519(unsigned.signableBytes(), signingPrivateKey) ?: return@synchronized null
unsigned.copy(signature = signature)
}
fun verifyAndIngest(
bundle: PrekeyBundle,
expectedNoiseKey: ByteArray,
announceBoundSigningKey: ByteArray,
nowMs: Long = System.currentTimeMillis()
): Boolean {
if (!bundle.noiseStaticPublicKey.contentEquals(expectedNoiseKey) ||
announceBoundSigningKey.size != PrekeyBundle.KEY_LENGTH ||
!verifyEd25519(bundle.signature, bundle.signableBytes(), announceBoundSigningKey)
) {
return false
}
synchronized(lock) {
val bundles = loadPeerBundlesLocked()
val key = encode(bundle.noiseStaticPublicKey)
val existing = bundles[key]
if (existing != null && existing.generatedAt >= bundle.generatedAt) return false
val freshIds = bundle.prekeys.map { it.id }.toSet()
bundles[key] = StoredPeerPrekeyBundle(
noiseKey = key,
generatedAt = bundle.generatedAt,
prekeyIds = bundle.prekeys.map { it.id },
prekeyPublicKeys = bundle.prekeys.map { encode(it.publicKey) },
usedIds = existing?.usedIds?.filterTo(mutableSetOf()) { it in freshIds } ?: mutableSetOf(),
assignments = existing?.assignments
?.filterValues { it in freshIds }
?.toMutableMap() ?: mutableMapOf(),
updatedAt = nowMs
)
while (bundles.size > MAX_PEERS) {
bundles.minByOrNull { it.value.updatedAt }?.key?.let(bundles::remove)
}
persistPeerBundlesLocked(bundles)
return true
}
}
fun seal(
payload: ByteArray,
messageId: String,
recipientNoiseKey: ByteArray,
recipientAdvertisesPrekeys: Boolean,
nowMs: Long = System.currentTimeMillis()
): Sealed {
val senderPrivateKey = identity.staticKey()?.first
?: throw IllegalStateException("Noise static identity is unavailable")
val assigned = if (recipientAdvertisesPrekeys) {
assignPrekey(messageId, recipientNoiseKey, nowMs)
} else {
null
}
return if (assigned != null) {
Sealed(
CourierNoiseCrypto.sealToPrekey(payload, senderPrivateKey, assigned),
assigned.id
)
} else {
Sealed(
CourierNoiseCrypto.seal(payload, senderPrivateKey, recipientNoiseKey),
null
)
}
}
fun open(
ciphertext: ByteArray,
prekeyId: Long?,
nowMs: Long = System.currentTimeMillis()
): Opened {
if (prekeyId == null) {
val staticPrivateKey = identity.staticKey()?.first
?: throw IllegalStateException("Noise static identity is unavailable")
val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey)
return Opened(opened.payload, opened.senderStaticKey, false)
}
synchronized(lock) {
val state = loadLocalLocked()
pruneLocked(state, nowMs)
val record = state.records.firstOrNull { it.id == prekeyId }
?: throw IllegalArgumentException("Unknown or expired courier prekey")
val consumedAt = record.consumedAt
if (consumedAt != null && nowMs - consumedAt > CONSUMED_GRACE_MS) {
throw IllegalArgumentException("Courier prekey grace window expired")
}
val privateKey = decode(record.privateKey)
?: throw IllegalArgumentException("Invalid courier prekey")
val opened = CourierNoiseCrypto.openWithPrekey(ciphertext, privateKey, prekeyId)
val newlyConsumed = record.consumedAt == null
if (newlyConsumed) {
record.consumedAt = nowMs
advanceGeneratedAtLocked(state, nowMs)
replenishLocked(state, nowMs)
persistLocalLocked(state)
}
return Opened(opened.payload, opened.senderStaticKey, newlyConsumed)
}
}
fun hasUsableBundle(
recipientNoiseKey: ByteArray,
nowMs: Long = System.currentTimeMillis()
): Boolean = synchronized(lock) {
val bundle = loadPeerBundlesLocked()[encode(recipientNoiseKey)] ?: return@synchronized false
isFresh(bundle, nowMs) && bundle.prekeyIds.any { it !in bundle.usedIds }
}
fun wipe() = synchronized(lock) {
local = LocalPrekeyState()
peerBundles = mutableMapOf()
localStore.clear()
peerStore.clear()
}
private fun assignPrekey(
messageId: String,
recipientNoiseKey: ByteArray,
nowMs: Long
): PrekeyBundle.Prekey? = synchronized(lock) {
val bundles = loadPeerBundlesLocked()
val key = encode(recipientNoiseKey)
val bundle = bundles[key] ?: return@synchronized null
if (!isFresh(bundle, nowMs)) return@synchronized null
bundle.assignments[messageId]?.let { assigned ->
val index = bundle.prekeyIds.indexOf(assigned)
if (index >= 0) {
return@synchronized decode(bundle.prekeyPublicKeys[index])
?.let { PrekeyBundle.Prekey(assigned, it) }
}
}
val index = bundle.prekeyIds.indices
.filter { bundle.prekeyIds[it] !in bundle.usedIds }
.minByOrNull { bundle.prekeyIds[it] }
?: return@synchronized null
val id = bundle.prekeyIds[index]
val publicKey = decode(bundle.prekeyPublicKeys[index]) ?: return@synchronized null
bundle.usedIds += id
bundle.assignments[messageId] = id
bundle.updatedAt = nowMs
persistPeerBundlesLocked(bundles)
PrekeyBundle.Prekey(id, publicKey)
}
private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean {
val beforeRecords = state.records.size
val beforeUnconsumed = state.records.count { it.consumedAt == null }
pruneLocked(state, nowMs)
val unconsumed = state.records.count { it.consumedAt == null }
var changed = unconsumed != beforeUnconsumed
if (unconsumed < REPLENISH_THRESHOLD) {
repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) {
val privateKey = randomBytes()
require(privateKey.size == PrekeyBundle.KEY_LENGTH)
state.records += LocalPrekeyRecord(
id = state.nextId and 0xFFFF_FFFFL,
privateKey = encode(privateKey),
createdAt = nowMs
)
state.nextId = (state.nextId + 1) and 0xFFFF_FFFFL
}
advanceGeneratedAtLocked(state, nowMs)
changed = true
}
if (changed || state.records.size != beforeRecords) persistLocalLocked(state)
return changed
}
private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) {
state.records.removeAll { record ->
record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS }
?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS)
}
}
private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) {
state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1)
}
private fun loadLocalLocked(): LocalPrekeyState {
local?.let { return it }
val loaded = localStore.load()
local = loaded
return loaded
}
private fun persistLocalLocked(state: LocalPrekeyState) {
try { localStore.save(state) } catch (error: Exception) {
local = null
throw error
}
}
private fun loadPeerBundlesLocked(): MutableMap<String, StoredPeerPrekeyBundle> {
peerBundles?.let { return it }
return peerStore.load().also { peerBundles = it }
}
private fun persistPeerBundlesLocked(bundles: Map<String, StoredPeerPrekeyBundle>) {
try { peerStore.save(bundles) } catch (error: Exception) {
peerBundles = null
throw error
}
}
private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean =
nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS
private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching {
Ed25519Signer().apply {
init(true, Ed25519PrivateKeyParameters(privateKey, 0))
update(data, 0, data.size)
}.generateSignature()
}.getOrNull()
private fun verifyEd25519(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean =
runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(data, 0, data.size)
}.verifySignature(signature)
}.getOrDefault(false)
private fun encode(data: ByteArray): String =
Base64.encodeToString(data, Base64.NO_WRAP)
private fun decode(value: String): ByteArray? =
runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull()
companion object {
private const val PEER_PREFS = "bitchat_prekey_bundles"
private const val REPLENISH_THRESHOLD = 3
private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000
private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000
private const val MAX_BUNDLE_AGE_MS = 7L * 24 * 60 * 60 * 1000
private const val MAX_PEERS = 200
@Volatile
private var instance: PrekeyManager? = null
fun getInstance(context: Context): PrekeyManager =
instance ?: synchronized(this) {
instance ?: run {
val application = context.applicationContext
val identityState = SecureIdentityStateManager(application)
val random = SecureRandom()
PrekeyManager(
identity = AndroidPrekeyIdentity(identityState),
localStore = SecureLocalPrekeyStore(identityState),
peerStore = SharedPreferencesPeerPrekeyStore(
application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE)
),
randomBytes = {
ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes)
}
).also { instance = it }
}
}
}
}

View File

@ -0,0 +1,109 @@
package com.bitchat.android.services.bridge
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.bitchat.android.identity.SecureIdentityStateManager
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
internal data class LocalPrekeyRecord(
val id: Long,
val privateKey: String,
val createdAt: Long,
var consumedAt: Long? = null
)
internal data class LocalPrekeyState(
var records: MutableList<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) {
check(this.state.storeSecureValueSynchronously(LOCAL_STORE_KEY, gson.toJson(state))) {
"Unable to persist courier prekeys"
}
}
override fun clear() {
check(state.clearSecureValuesSynchronously(LOCAL_STORE_KEY))
}
private companion object {
const val TAG = "LocalPrekeyStore"
const val LOCAL_STORE_KEY = "courier_prekeys_v1"
}
}
internal class SharedPreferencesPeerPrekeyStore(
private val preferences: SharedPreferences,
private val gson: Gson = Gson()
) : PeerPrekeyStore {
override fun load(): MutableMap<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>) {
check(preferences.edit().putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())).commit())
}
override fun clear() {
check(preferences.edit().clear().commit())
}
private companion object {
const val PEER_BUNDLES_KEY = "bundles_v1"
}
}

View File

@ -133,7 +133,7 @@ class GossipSyncManager(
// Only ANNOUNCE or broadcast MESSAGE
val mt = MessageType.fromValue(packet.type)
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
val isGroupMessage = mt == MessageType.GROUP_MESSAGE &&
val isGroupMessage = mt in setOf(MessageType.GROUP_MESSAGE, MessageType.PREKEY_BUNDLE) &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
@ -149,7 +149,8 @@ class GossipSyncManager(
val age = System.currentTimeMillis() - packet.timestamp.toLong()
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
synchronized(groupMessages) {
groupMessages[id] = packet
val cacheKey = if (mt == MessageType.PREKEY_BUNDLE) "prekey-${packet.senderID.toHexString()}" else id
groupMessages[cacheKey] = packet
while (groupMessages.size > 200) {
val iterator = groupMessages.entries.iterator()
iterator.next(); iterator.remove()
@ -204,7 +205,7 @@ class GossipSyncManager(
SyncTypeFlags.PUBLIC_MESSAGES,
SyncTypeFlags.FRAGMENT,
SyncTypeFlags.FILE_TRANSFER,
SyncTypeFlags.GROUP_MESSAGE
SyncTypeFlags.GROUP_MESSAGE.union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
).forEach { types ->
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
@ -226,7 +227,7 @@ class GossipSyncManager(
private fun sendRequestSyncToPeer(peerID: String, requestedTypes: SyncTypeFlags? = null) {
val types = requestedTypes ?: SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
.union(SyncTypeFlags.GROUP_MESSAGE)
.union(SyncTypeFlags.GROUP_MESSAGE).union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
.let { if (boardPacketsProvider != null && peerSupportsBoard(peerID)) it.union(SyncTypeFlags.BOARD) else it }
val payload = buildGcsPayload(types)
@ -286,8 +287,8 @@ class GossipSyncManager(
}
}
if (requestedTypes.contains(MessageType.GROUP_MESSAGE)) {
synchronized(groupMessages) { groupMessages.values.toList() }.forEach { packet ->
if (requestedTypes.contains(MessageType.GROUP_MESSAGE) || requestedTypes.contains(MessageType.PREKEY_BUNDLE)) {
typedSyncCandidates(requestedTypes).forEach { packet ->
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
@ -314,8 +315,19 @@ class GossipSyncManager(
}
}
private fun typedSyncCandidates(types: SyncTypeFlags): List<BitchatPacket> = synchronized(groupMessages) {
val now = System.currentTimeMillis()
groupMessages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS }
groupMessages.values.filter { packet ->
MessageType.fromValue(packet.type)?.let(types::contains) == true
}
}
private fun shouldRespondTo(peerID: String): Boolean {
val now = System.currentTimeMillis()
responseTimesByPeer.entries.removeIf { (_, times) ->
synchronized(times) { times.isEmpty() || now - times.last() >= 30_000L }
}
if (responseTimesByPeer.size >= 256 && !responseTimesByPeer.containsKey(peerID)) return false
val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() }
return synchronized(times) {
@ -360,9 +372,7 @@ class GossipSyncManager(
// Collect only the packet types represented by this filter.
val list = ArrayList<BitchatPacket>()
if (types.contains(MessageType.BOARD_POST)) list.addAll(boardPacketsProvider?.invoke().orEmpty().take(200))
if (types.contains(MessageType.GROUP_MESSAGE)) {
synchronized(groupMessages) { list.addAll(groupMessages.values) }
}
list.addAll(typedSyncCandidates(types))
if (types.contains(MessageType.ANNOUNCE)) {
for ((_, pair) in latestAnnouncementByPeer) {
list.add(pair.second)

View File

@ -6,7 +6,7 @@ import com.bitchat.android.protocol.MessageType
@JvmInline
value class SyncTypeFlags private constructor(val rawValue: ULong) {
companion object {
private const val KNOWN_TYPE_MASK: ULong = 0x5ffu
private const val KNOWN_TYPE_MASK: ULong = 0x7ffu
val BOARD = fromMessageTypes(MessageType.BOARD_POST)
val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE)
@ -48,7 +48,9 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) {
MessageType.REQUEST_SYNC -> 6
MessageType.FILE_TRANSFER -> 7
MessageType.BOARD_POST -> 8
MessageType.PREKEY_BUNDLE -> 9
MessageType.GROUP_MESSAGE -> 10
MessageType.NOSTR_CARRIER,
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME -> null
}

View File

@ -303,11 +303,13 @@ 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
// Get version name from package info
val versionName = remember {
try {
@ -544,6 +546,19 @@ fun AboutSheet(
}
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
SettingsToggleRow(
icon = Icons.Filled.Public,
title = stringResource(R.string.mesh_bridge_title),
subtitle = stringResource(R.string.mesh_bridge_description),
checked = bridgeEnabled,
onCheckedChange = onBridgeEnabledChange
)
HorizontalDivider(
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
@ -1303,7 +1318,7 @@ fun PasswordPromptDialog(
) {
if (show && channelName != null) {
val colorScheme = MaterialTheme.colorScheme
AlertDialog(
onDismissRequest = onDismiss,
title = {
@ -1321,7 +1336,7 @@ fun PasswordPromptDialog(
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = passwordInput,
onValueChange = onPasswordChange,

View File

@ -564,6 +564,7 @@ fun NicknameEditor(
@Composable
fun PeerCounter(
connectedPeers: List<String>,
bridgedPeopleCount: Int = 0,
joinedChannels: Set<String>,
hasUnreadChannels: Map<String, Int>,
isConnected: Boolean,
@ -586,8 +587,8 @@ fun PeerCounter(
is com.bitchat.android.geohash.ChannelID.Mesh,
null -> {
// Mesh channel: show Bluetooth-connected peers (excluding self)
val count = connectedPeers.size
Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary)
val count = connectedPeers.size + bridgedPeopleCount
Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) colorScheme.secondary else palette.textTertiary)
}
}
@ -727,6 +728,7 @@ private fun MainHeader(
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val crowdingMode = headerCrowdingMode(maxWidth)
@ -809,6 +811,7 @@ private fun MainHeader(
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
bridgedPeopleCount = bridgeUiState.participants.size,
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
isConnected = isConnected,

View File

@ -83,6 +83,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@ -443,7 +444,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme,
showMediaButtons = showMediaButtons
showMediaButtons = showMediaButtons,
showBridgeControls = bridgeUiState.enabled &&
currentChannel == null &&
selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location,
nearbyOnly = bridgeUiState.nearbyOnly,
onNearbyOnlyChange = viewModel::setBridgeNearbyOnly
)
}
}
@ -645,7 +651,10 @@ fun ChatInputSection(
colorScheme: ColorScheme,
showMediaButtons: Boolean,
recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showBridgeControls: Boolean = false,
nearbyOnly: Boolean = false,
onNearbyOnlyChange: (Boolean) -> Unit = {}
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activePublicTalker by remember(context) {
@ -726,6 +735,9 @@ fun ChatInputSection(
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
showBridgeControls = showBridgeControls,
nearbyOnly = nearbyOnly,
onNearbyOnlyChange = onNearbyOnlyChange,
mentionPeerIdentities = mentionPeerIdentities,
recorderFactory = recorderFactory,
activePublicTalker = activePublicTalker,
@ -840,6 +852,7 @@ private fun ChatDialogs(
onMeshPeerListDismiss: () -> Unit,
) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
// Password dialog
PasswordPromptDialog(
@ -856,7 +869,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

@ -40,6 +40,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
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.VoiceRecorder
@ -616,6 +618,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
@ -1566,6 +1583,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)
}
@ -1718,6 +1743,7 @@ class ChatViewModel(
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
mediaSendingManager.clearPendingPrivateMediaConsent()
MeshBridgeService.wipe()
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
// any transport callback already in flight until the fresh identity is ready.
@ -1763,7 +1789,7 @@ class ChatViewModel(
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationManager.clearPersistedChannel()
locationManager.panicReset()
} catch (_: Exception) { }
geohashViewModel.panicReset()

View File

@ -14,9 +14,21 @@ class DataManager(private val context: Context) {
companion object {
private const val TAG = "DataManager"
private const val PREFS_NAME = "bitchat_prefs"
private const val BLOCKED_USERS_KEY = "blocked_users"
/**
* Reads the current persisted block list for ingress paths that have a
* full Noise key but no live mesh peer/session.
*/
fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getStringSet(BLOCKED_USERS_KEY, emptySet())
?.contains(fingerprint) == true
}
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val gson = Gson()
// Channel-related maps that need to persist state
@ -200,13 +212,13 @@ class DataManager(private val context: Context) {
@Synchronized
fun loadBlockedUsers() {
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet()
_blockedUsers.clear()
_blockedUsers.addAll(savedBlockedUsers)
}
fun saveBlockedUsers() {
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply()
}
fun addBlockedUser(fingerprint: String) {

View File

@ -320,7 +320,10 @@ fun MessageInput(
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
recorderFactory: ((String?, String?) -> VoiceRecorder)? = null,
activePublicTalker: String? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showBridgeControls: Boolean = false,
nearbyOnly: Boolean = false,
onNearbyOnlyChange: (Boolean) -> Unit = {}
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
@ -405,6 +408,25 @@ fun MessageInput(
modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom
) {
if (showBridgeControls) {
IconToggleButton(
checked = nearbyOnly,
onCheckedChange = onNearbyOnlyChange,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (nearbyOnly) Icons.Filled.Bluetooth else Icons.Filled.Public,
contentDescription = if (nearbyOnly) {
stringResource(R.string.cd_nearby_only_on)
} else {
stringResource(R.string.cd_nearby_only_off)
},
tint = if (nearbyOnly) Color(0xFFFF9500) else Color(0xFF00A7C4),
modifier = Modifier.size(18.dp)
)
}
}
// MARK: - The pill. Field and action buttons are one visual object.
Row(
modifier = Modifier

View File

@ -160,6 +160,8 @@ class MeshDelegateHandler(
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
coroutineScope.launch {
com.bitchat.android.services.MessageRouter.tryGetInstance()
?.onMessageAcknowledged(messageID, recipientPeerID)
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date()))
}
}

View File

@ -73,6 +73,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.BridgedParticipant
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@ -111,6 +112,7 @@ fun MeshPeerListSheet(
val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle()
val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle()
val geohashPeopleCount = geohashPeople.size
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
val directPeerIdentityIDs = remember(peerDirect) {
@ -451,6 +453,13 @@ fun MeshPeerListSheet(
onDismiss()
}
)
if (bridgeUiState.enabled && bridgeUiState.participants.isNotEmpty()) {
BridgedPeopleSection(
participants = bridgeUiState.participants,
colorScheme = colorScheme
)
}
}
}
}
@ -687,6 +696,53 @@ private fun GroupSection(
}
}
@Composable
private fun BridgedPeopleSection(
participants: List<BridgedParticipant>,
colorScheme: ColorScheme
) {
Column(modifier = Modifier.padding(top = 16.dp)) {
Text(
text = stringResource(R.string.across_bridge).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
participants.forEach { participant ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = null,
tint = Color(0xFF00A7C4),
modifier = Modifier.size(18.dp)
)
Column {
Text(
text = participant.displayName,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = colorScheme.onSurface
)
Text(
text = stringResource(R.string.via_mesh_bridge),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.55f)
)
}
}
}
}
}
@Composable
private fun ChannelRow(
channel: String,

View File

@ -1372,6 +1372,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendMessage(content, mentions, channel)
}
override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
meshCore.sendNostrCarrier(payload, recipientPeerID)
}
override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
meshCore.sendCourierEnvelope(payload, recipientPeerID)
}
override fun sendPrekeyBundle(payload: ByteArray) {
meshCore.sendPrekeyBundle(payload)
}
/**
* Sends a private encrypted message to a specific peer.
*

View File

@ -611,6 +611,13 @@
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
<string name="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string>
<string name="mesh_bridge_title" tools:ignore="MissingTranslation">mesh bridge</string>
<string name="mesh_bridge_description" tools:ignore="MissingTranslation">share public nearby messages through relays and carry encrypted offline messages for others</string>
<string name="across_bridge" tools:ignore="MissingTranslation">across the bridge</string>
<string name="via_mesh_bridge" tools:ignore="MissingTranslation">via mesh bridge</string>
<string name="cd_mesh_bridge_active" tools:ignore="MissingTranslation">Mesh bridge active</string>
<string name="cd_nearby_only_on" tools:ignore="MissingTranslation">Nearby only: messages stay within radio range</string>
<string name="cd_nearby_only_off" tools:ignore="MissingTranslation">Bridged: messages also reach people across the bridge</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">Mention</string>
<string name="image_counter">%1$d / %2$d</string>

View File

@ -0,0 +1,163 @@
package com.bitchat.android.model
import com.bitchat.android.noise.CourierNoiseCrypto
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.nostr.NostrIdentity
import com.bitchat.android.nostr.NostrKind
import com.bitchat.android.nostr.NostrProtocol
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BridgeProtocolInteropTest {
@Test
fun `carrier TLVs match the iOS wire fixture`() {
val carrier = NostrCarrierPacket(
direction = NostrCarrierPacket.Direction.TO_BRIDGE,
geohash = "u4pruy",
eventJson = "{}".toByteArray()
)
assertEquals(
"010001030200067534707275790300027b7d",
carrier.encode().toHex()
)
assertEquals(carrier, NostrCarrierPacket.decode(carrier.encode()))
assertNull(NostrCarrierPacket.decode(carrier.encode().dropLast(1).toByteArray()))
}
@Test
fun `carrier decoder skips unknown TLVs`() {
val encoded = NostrCarrierPacket(
NostrCarrierPacket.Direction.FROM_BRIDGE,
"u4pruy",
"{}".toByteArray()
).encode()
val withUnknown = encoded + byteArrayOf(0x7F, 0x00, 0x02, 0x12, 0x34)
assertEquals(
NostrCarrierPacket.Direction.FROM_BRIDGE,
NostrCarrierPacket.decode(withUnknown)?.direction
)
}
@Test
fun `courier envelope and daily tag match iOS fixtures`() {
val envelope = CourierEnvelope(
recipientTag = ByteArray(16) { it.toByte() },
expiry = 0x0102_0304_0506_0708uL,
ciphertext = byteArrayOf(0xAA.toByte(), 0xBB.toByte()),
copies = 3u,
prekeyID = 0x89AB_CDEFu
)
assertEquals(
"010010000102030405060708090a0b0c0d0e0f" +
"0200080102030405060708" +
"030002aabb04000103" +
"05000489abcdef",
envelope.encode()!!.toHex()
)
assertEquals(envelope, CourierEnvelope.decode(envelope.encode()!!))
assertEquals(
"f7b87836e588a2b31b306605b3313744",
CourierEnvelope.recipientTag(ByteArray(32) { it.toByte() }, 1u).toHex()
)
}
@Test
fun `signed prekey canonical bytes match iOS fixture`() {
val bundle = PrekeyBundle(
noiseStaticPublicKey = ByteArray(32) { 0x11 },
prekeys = listOf(
PrekeyBundle.Prekey(0x0102_0304, ByteArray(32) { 0x22 })
),
generatedAt = 0x0102_0304_0506_0708L,
signature = ByteArray(64) { 0x33 }
)
assertEquals(
"18" +
"626974636861742d7072656b65792d62756e646c652d7631" +
"11".repeat(32) +
"01" +
"01020304" +
"22".repeat(32) +
"0102030405060708",
bundle.signableBytes().toHex()
)
assertEquals(bundle, PrekeyBundle.decode(bundle.encode()!!))
}
@Test
fun `courier Noise X opens static and one-time prekey ciphertexts`() {
val senderPrivate = ByteArray(32) { (it + 1).toByte() }
val recipientPrivate = ByteArray(32) { (it + 33).toByte() }
val payload = "offline hello".toByteArray()
val staticCiphertext = CourierNoiseCrypto.seal(
payload,
senderPrivate,
CourierNoiseCrypto.publicKey(recipientPrivate)
)
val staticOpened = CourierNoiseCrypto.open(staticCiphertext, recipientPrivate)
assertArrayEquals(payload, staticOpened.payload)
assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), staticOpened.senderStaticKey)
val prekey = PrekeyBundle.Prekey(
id = 0x89AB_CDEFL,
publicKey = CourierNoiseCrypto.publicKey(recipientPrivate)
)
val prekeyCiphertext = CourierNoiseCrypto.sealToPrekey(payload, senderPrivate, prekey)
val prekeyOpened = CourierNoiseCrypto.openWithPrekey(
prekeyCiphertext,
recipientPrivate,
prekey.id
)
assertArrayEquals(payload, prekeyOpened.payload)
assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), prekeyOpened.senderStaticKey)
}
@Test
fun `public message stable identity matches cross-language fixture`() {
val id = MeshMessageIdentity.stableId(
senderIdHex = "0011223344556677",
timestampMs = 1_750_000_000_123,
content = "hello mesh"
)
assertEquals("b83f94d81dcdd1b0c0048f6645995dd4", id)
assertTrue(id.all { it in '0'..'9' || it in 'a'..'f' })
}
@Test
fun `bridge Nostr event uses signed rendezvous tags`() {
val identity = NostrIdentity.fromPrivateKey("01".padStart(64, '0'))
val event = NostrProtocol.createBridgeMeshEvent(
content = "hello mesh",
cell = "u4pruy",
senderIdentity = identity,
nickname = "alice",
meshSenderId = "0011223344556677",
meshTimestampMs = 1_750_000_000_123
)
assertEquals(NostrKind.EPHEMERAL_EVENT, event.kind)
assertEquals(listOf("r", "u4pruy"), event.tags[0])
assertEquals(listOf("n", "alice"), event.tags[1])
assertEquals(
listOf(
"m",
"b83f94d81dcdd1b0c0048f6645995dd4",
"0011223344556677",
"1750000000123"
),
event.tags[2]
)
assertTrue(event.isValidSignature())
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -67,6 +67,18 @@ class IdentityAnnouncementTest {
assertEquals(PeerCapabilities.NONE, decoded.capabilities)
}
@Test
fun `oversized bridge cell is omitted without dropping announcement`() {
val encoded = IdentityAnnouncement(
nickname,
noiseKey,
signingKey,
bridgeGeohash = "u".repeat(13)
).encode()!!
assertNull(IdentityAnnouncement.decode(encoded)?.bridgeGeohash)
}
@Test
fun `unknown capability bits and TLVs survive decode and re-encode`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
@ -92,12 +104,13 @@ class IdentityAnnouncementTest {
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
assertArrayEquals(
byteArrayOf(0x05, 0x02, 0x38, 0x01),
byteArrayOf(0x05, 0x02, 0x39, 0x01),
encoded.takeLast(4).toByteArray()
)
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!
assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA))
assertTrue(capabilities.contains(PeerCapabilities.GROUPS))
assertTrue(capabilities.contains(PeerCapabilities.VOUCH))
assertTrue(capabilities.contains(PeerCapabilities.PREKEYS))
}
}

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

@ -75,17 +75,38 @@ class NostrProtocolTest {
assertTrue(event.isValidSignature())
}
@Test
fun rejectsMalformedAuthenticatedEnvelopes() {
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
val malformed = listOf(
forgedGiftWrap("hello", sender, sender, recipient, rumorKind = NostrKind.TEXT_NOTE),
forgedGiftWrap("hello", sender, sender, recipient, rumorTags = listOf(listOf("p", sender.publicKeyHex))),
forgedGiftWrap("hello", sender, sender, recipient, sealTags = listOf(listOf("p", recipient.publicKeyHex))),
forgedGiftWrap("hello", sender, sender, recipient, rumorTimestamp = 1),
forgedGiftWrap("hello", sender, sender, recipient, outerTags = emptyList())
)
malformed.forEach { assertNull(NostrProtocol.decryptPrivateMessage(it, recipient)) }
val ios = forgedGiftWrap("hello", sender, sender, recipient, rumorTags = emptyList())
assertEquals("hello", NostrProtocol.decryptPrivateMessage(ios, recipient)?.first)
}
private fun forgedGiftWrap(
content: String,
claimedSender: NostrIdentity,
sealSigner: NostrIdentity,
recipient: NostrIdentity
recipient: NostrIdentity,
rumorKind: Int = NostrKind.DIRECT_MESSAGE,
rumorTags: List<List<String>> = listOf(listOf("p", recipient.publicKeyHex)),
sealTags: List<List<String>> = emptyList(),
rumorTimestamp: Int = (System.currentTimeMillis() / 1000).toInt(),
outerTags: List<List<String>> = listOf(listOf("p", recipient.publicKeyHex))
): NostrEvent {
val rumorBase = NostrEvent(
pubkey = claimedSender.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.DIRECT_MESSAGE,
tags = listOf(listOf("p", recipient.publicKeyHex)),
createdAt = rumorTimestamp,
kind = rumorKind,
tags = rumorTags,
content = content
)
val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex())
@ -98,7 +119,7 @@ class NostrProtocolTest {
pubkey = sealSigner.publicKeyHex,
createdAt = NostrCrypto.randomizeTimestampUpToPast(),
kind = NostrKind.SEAL,
tags = emptyList(),
tags = sealTags,
content = sealContent
).sign(sealSigner.privateKeyHex)
@ -112,7 +133,7 @@ class NostrProtocolTest {
pubkey = wrapPublicKey,
createdAt = NostrCrypto.randomizeTimestampUpToPast(),
kind = NostrKind.GIFT_WRAP,
tags = listOf(listOf("p", recipient.publicKeyHex)),
tags = outerTags,
content = giftWrapContent
).sign(wrapPrivateKey)
}

View File

@ -0,0 +1,57 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.mesh.BridgeOutboundPolicy
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BridgeRaceRegressionTest {
@Test
fun `later opt in cannot authorize a send that was previously denied`() {
val policyAtSend = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false)
val policyAtPublish = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
assertFalse(policyAtSend.permitsPublication(policyAtPublish))
}
@Test
fun `later opt out still blocks a send that was previously allowed`() {
val policyAtSend = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
val policyAtPublish = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false)
assertFalse(policyAtSend.permitsPublication(policyAtPublish))
}
@Test
fun `publication requires bridge permission at send and publish time`() {
val allowed = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
val nearbyOnly = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = true)
assertTrue(allowed.permitsPublication(allowed))
assertFalse(nearbyOnly.permitsPublication(allowed))
assertFalse(allowed.permitsPublication(nearbyOnly))
}
@Test
fun `delayed close targets the retired subscription generation`() {
val slot = RelaySubscriptionSlot("bridge")
val activeOnRelay = mutableSetOf<String>()
val delayedCloses = mutableListOf<String>()
val first = slot.replace(
close = delayedCloses::add,
open = activeOnRelay::add
)
val second = slot.replace(
close = delayedCloses::add,
open = activeOnRelay::add
)
delayedCloses.forEach(activeOnRelay::remove)
assertNotEquals(first, second)
assertFalse(first in activeOnRelay)
assertTrue(second in activeOnRelay)
}
}

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

View File

@ -71,7 +71,7 @@ class GossipSyncGroupTest {
type = type.value,
senderID = ByteArray(8) { 0x11 },
recipientID = SpecialRecipients.BROADCAST,
timestamp = 1u,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 1u
)

View File

@ -22,7 +22,7 @@ class SyncTypeFlagsTest {
@Test
fun `unknown extended bits are normalized away`() {
val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xfc.toByte()))!!
val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xf8.toByte()))!!
assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, decoded)
assertArrayEquals(byteArrayOf(0x03), decoded.encode())

View File

@ -81,6 +81,8 @@ composeCompiler {
// onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice
// features, and the phone's foreground service.
val sharedSourceIncludes = listOf(
"com/bitchat/android/nostr/MeshMessageIdentity.kt",
"com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt",
"com/bitchat/android/board/BoardPackets.kt",
"com/bitchat/android/board/BoardStore.kt",
"com/bitchat/android/protocol/**",
@ -114,6 +116,7 @@ val sharedSourceIncludes = listOf(
"com/bitchat/android/util/BinaryEncodingUtils.kt",
)
val sharedSourceExcludes = listOf(
"com/bitchat/android/model/NostrCarrierPacket.kt",
"com/bitchat/android/model/FileSharingManager.kt",
// Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service
// (MeshCore-style) in M2 instead of reusing these.