diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 7b5926ef..785dcf9b 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 46f05950..c793c163 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) { /** * Refresh available channels from current location */ - fun refreshChannels() { + fun refreshChannels( + forceFresh: Boolean = false, + updatePlaceNames: Boolean = true + ) { if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) { - requestOneShotLocation() + requestOneShotLocation(forceFresh, updatePlaceNames) } } @@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) { // MARK: - Location Operations - private fun requestOneShotLocation() { + private fun requestOneShotLocation( + forceFresh: Boolean = false, + updatePlaceNames: Boolean = true + ) { if (!isLocationServicesEnabled() || syncPermissionState() != PermissionState.AUTHORIZED ) { @@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) { val token = LiveLocationPrivacyGate.captureToken() ?: return _isLoadingLocation.value = true + if (forceFresh) { + requestFreshLocation(token, updatePlaceNames) + return + } + val started = LiveLocationPrivacyGate.runIfAllowed(token) { locationProvider.getLastKnownLocation { cached -> if (!canUseLiveLocation(token)) return@getLastKnownLocation if (cached != null) { - onLocationUpdated(cached, token) + onLocationUpdated(cached, token, updatePlaceNames) } else { - LiveLocationPrivacyGate.runIfAllowed(token) { - locationProvider.requestFreshLocation { fresh -> - if (!canUseLiveLocation(token)) return@requestFreshLocation - - if (fresh != null) { - onLocationUpdated(fresh, token) - } else { - Log.w(TAG, "Failed to get fresh location") - _isLoadingLocation.value = false - } - } - } + requestFreshLocation(token, updatePlaceNames) } } } if (!started) _isLoadingLocation.value = false } - private fun onLocationUpdated(location: Location, token: Long) { + private fun requestFreshLocation( + token: Long, + updatePlaceNames: Boolean + ) { + val started = LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.requestFreshLocation { fresh -> + if (!canUseLiveLocation(token)) return@requestFreshLocation + if (fresh != null) { + onLocationUpdated(fresh, token, updatePlaceNames) + } else { + Log.w(TAG, "Failed to get fresh location") + _isLoadingLocation.value = false + } + } + } + if (!started) _isLoadingLocation.value = false + } + + private fun onLocationUpdated( + location: Location, + token: Long, + updatePlaceNames: Boolean = true + ) { LiveLocationPrivacyGate.runIfAllowed(token) { if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed _isLoadingLocation.value = false computeChannels(location, token) - reverseGeocodeIfNeeded(location, token) + if (updatePlaceNames) reverseGeocodeIfNeeded(location, token) } } @@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) { _teleported.value = false } + /** Remove exact/transient location state without destroying the singleton. */ + fun panicReset() { + clearLiveLocationState() + clearPersistedChannel() + } + // MARK: - Location Services State Persistence /** diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 13e682b4..ab9e2ece 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -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() } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 62e65309..d2ec0f2c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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 = 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, diff --git a/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt new file mode 100644 index 00000000..f803aeef --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt @@ -0,0 +1,98 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket + +/** + * Immutable privacy decision captured when a public message is accepted. + * + * A later opt-in must not authorize a message that was composed while + * bridging was disabled or nearby-only. Publication therefore requires both + * this send-time decision and the current policy to allow bridging. + */ +class BridgeOutboundPolicy internal constructor( + internal val enabled: Boolean, + internal val nearbyOnly: Boolean +) { + val allowsBridging: Boolean + get() = enabled && !nearbyOnly + + fun permitsPublication(current: BridgeOutboundPolicy): Boolean = + allowsBridging && current.allowsBridging + + companion object { + val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false) + + internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy = + BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly) + } +} + +/** + * Transport-facing bridge boundary. + * + * BLE/Wi-Fi packet code depends only on this protocol surface; application + * bootstrap installs the process bridge controller. Tests can install a fake + * without constructing relay or persistence infrastructure. + */ +interface BridgeMeshDelegate { + fun advertisedCell(): String? + fun outboundPolicy(): BridgeOutboundPolicy + + fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String?, + policyAtSend: BridgeOutboundPolicy + ) + + fun handleAuthenticatedRadioMessage(messageId: String) + fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) + fun handlePrekeyPacket(packet: BitchatPacket) + fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) +} + +object BridgeMeshPort : BridgeMeshDelegate { + @Volatile + private var delegate: BridgeMeshDelegate? = null + + fun install(delegate: BridgeMeshDelegate) { + this.delegate = delegate + } + + override fun advertisedCell(): String? = delegate?.advertisedCell() + + override fun outboundPolicy(): BridgeOutboundPolicy = + delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied + + override fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String?, + policyAtSend: BridgeOutboundPolicy + ) { + delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend) + } + + override fun handleAuthenticatedRadioMessage(messageId: String) { + delegate?.handleAuthenticatedRadioMessage(messageId) + } + + override fun handleVerifiedAnnouncement( + peerId: String, + announcement: IdentityAnnouncement + ) { + delegate?.handleVerifiedAnnouncement(peerId, announcement) + } + + override fun handlePrekeyPacket(packet: BitchatPacket) { + delegate?.handlePrekeyPacket(packet) + } + + override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { + delegate?.handleCarrier(payload, fromPeerId, directedToUs) + } + +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 15b290af..d4d8d859 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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 = 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, diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 65288fbf..2b138b06 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -13,6 +13,9 @@ interface MeshService { fun stopServices() fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) + fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) + fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) + fun sendPrekeyBundle(payload: ByteArray) fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index a733ae86..4c73e1ec 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -9,6 +9,7 @@ import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.sync.PacketIdUtil +import com.bitchat.android.nostr.MeshMessageIdentity import com.bitchat.android.util.toHexString import com.bitchat.android.features.voice.LiveVoiceManager import com.bitchat.android.features.voice.LiveVoiceScope @@ -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}") } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index d35a26b4..5b2f9219 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index d122c3ec..338f01f1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 0112a7f1..488fd5f2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index a70fc069..c0ebf74b 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -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 { /** diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index c48bd6cc..21ccbc5e 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -13,7 +13,8 @@ data class IdentityAnnouncement( val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) val signingPublicKey: ByteArray, // Ed25519 public key for signing val capabilities: PeerCapabilities? = null, - val unknownTLVs: List = emptyList() + val unknownTLVs: List = emptyList(), + val bridgeGeohash: String? = null ) : Parcelable { /** @@ -23,7 +24,8 @@ data class IdentityAnnouncement( NICKNAME(0x01u), NOISE_PUBLIC_KEY(0x02u), SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key - CAPABILITIES(0x05u); + CAPABILITIES(0x05u), + BRIDGE_GEOHASH(0x06u); companion object { fun fromValue(value: UByte): TLVType? { @@ -39,6 +41,9 @@ data class IdentityAnnouncement( val nicknameData = nickname.toByteArray(Charsets.UTF_8) // Check size limits + val bridgeGeohashData = bridgeGeohash + ?.toByteArray(Charsets.UTF_8) + ?.takeIf { it.size in 1..12 } if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 || unknownTLVs.any { it.value.size > 255 }) { return null @@ -68,6 +73,12 @@ data class IdentityAnnouncement( result.addAll(capabilityBytes.toList()) } + bridgeGeohashData?.let { geohash -> + result.add(TLVType.BRIDGE_GEOHASH.value.toByte()) + result.add(geohash.size.toByte()) + result.addAll(geohash.toList()) + } + // Preserve extensions this build does not understand. This includes // gossip TLV 0x04 when an announcement is decoded through this model. unknownTLVs.forEach { tlv -> @@ -92,6 +103,7 @@ data class IdentityAnnouncement( var noisePublicKey: ByteArray? = null var signingPublicKey: ByteArray? = null var capabilities: PeerCapabilities? = null + var bridgeGeohash: String? = null val unknownTLVs = mutableListOf() while (offset + 2 <= dataCopy.size) { @@ -125,6 +137,10 @@ data class IdentityAnnouncement( TLVType.CAPABILITIES -> { capabilities = PeerCapabilities.decode(value) } + TLVType.BRIDGE_GEOHASH -> { + if (value.size !in 1..12) return null + bridgeGeohash = String(value, Charsets.UTF_8) + } null -> { // Retain unknown extensions so callers can forward or // re-encode the announcement without erasing them. @@ -135,7 +151,14 @@ data class IdentityAnnouncement( // All three fields are required return if (nickname != null && noisePublicKey != null && signingPublicKey != null) { - IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs) + IdentityAnnouncement( + nickname, + noisePublicKey, + signingPublicKey, + capabilities, + unknownTLVs, + bridgeGeohash + ) } else { null } @@ -145,12 +168,14 @@ data class IdentityAnnouncement( fun forLocalPeer( nickname: String, noisePublicKey: ByteArray, - signingPublicKey: ByteArray + signingPublicKey: ByteArray, + bridgeGeohash: String? = null ): IdentityAnnouncement = IdentityAnnouncement( nickname = nickname, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, - capabilities = PeerCapabilities.LOCAL_SUPPORTED + capabilities = PeerCapabilities.localSupported(), + bridgeGeohash = bridgeGeohash ) } @@ -166,6 +191,7 @@ data class IdentityAnnouncement( if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false if (capabilities != other.capabilities) return false if (unknownTLVs != other.unknownTLVs) return false + if (bridgeGeohash != other.bridgeGeohash) return false return true } @@ -176,10 +202,11 @@ data class IdentityAnnouncement( result = 31 * result + signingPublicKey.contentHashCode() result = 31 * result + (capabilities?.hashCode() ?: 0) result = 31 * result + unknownTLVs.hashCode() + result = 31 * result + (bridgeGeohash?.hashCode() ?: 0) return result } override fun toString(): String { - return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})" + return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue}, bridgeGeohash=$bridgeGeohash)" } } diff --git a/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt new file mode 100644 index 00000000..3f6be5a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt @@ -0,0 +1,101 @@ +package com.bitchat.android.model + +import com.bitchat.android.nostr.NostrEvent + +/** + * Wire payload for MessageType.NOSTR_CARRIER (0x28). + * + * The TLV layout and limits intentionally match iOS. Lengths are unsigned + * 16-bit big-endian values and unknown TLVs are skipped. + */ +data class NostrCarrierPacket( + val direction: Direction, + val geohash: String, + val eventJson: ByteArray +) { + enum class Direction(val value: Int) { + TO_GATEWAY(0x01), + FROM_GATEWAY(0x02), + TO_BRIDGE(0x03), + FROM_BRIDGE(0x04); + + companion object { + fun fromValue(value: Int): Direction? = entries.firstOrNull { it.value == value } + } + } + + init { + require(geohash.toByteArray(Charsets.UTF_8).size in 1..MAX_GEOHASH_LENGTH) + require(eventJson.size in 1..MAX_EVENT_JSON_BYTES) + } + + fun event(): NostrEvent? = + NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8)) + + fun encode(): ByteArray { + return checkNotNull( + Tlv16Codec.encode( + Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())), + Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)), + Tlv16Codec.Field(TLV_EVENT_JSON, eventJson) + ) + ) + } + + companion object { + const val MAX_EVENT_JSON_BYTES = 16 * 1024 + const val MAX_GEOHASH_LENGTH = 12 + + private const val TLV_DIRECTION = 0x01 + private const val TLV_GEOHASH = 0x02 + private const val TLV_EVENT_JSON = 0x03 + + fun fromEvent(direction: Direction, geohash: String, event: NostrEvent): NostrCarrierPacket? = + runCatching { + NostrCarrierPacket( + direction = direction, + geohash = geohash, + eventJson = event.toJsonString().toByteArray(Charsets.UTF_8) + ) + }.getOrNull() + + fun decode(data: ByteArray): NostrCarrierPacket? { + var direction: Direction? = null + var geohash: String? = null + var eventJson: ByteArray? = null + + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { + TLV_DIRECTION -> { + if (field.value.size != 1) return null + direction = + Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null + } + TLV_GEOHASH -> { + geohash = field.value.toString(Charsets.UTF_8) + } + TLV_EVENT_JSON -> eventJson = field.value + } + } ?: return null + + return runCatching { + NostrCarrierPacket( + direction = direction ?: return null, + geohash = geohash ?: return null, + eventJson = eventJson ?: return null + ) + }.getOrNull() + } + + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is NostrCarrierPacket && + direction == other.direction && + geohash == other.geohash && + eventJson.contentEquals(other.eventJson)) + + override fun hashCode(): Int = + 31 * (31 * direction.hashCode() + geohash.hashCode()) + eventJson.contentHashCode() +} diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index 0327a00a..9775596b 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt new file mode 100644 index 00000000..be5d7a81 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt @@ -0,0 +1,171 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Signed batch of one-time Curve25519 keys carried by MessageType.PREKEY_BUNDLE (0x24). + * + * The canonical signing bytes and TLV representation intentionally match the + * iOS BitFoundation implementation byte-for-byte. + */ +data class PrekeyBundle( + val noiseStaticPublicKey: ByteArray, + val prekeys: List, + val generatedAt: Long, + val signature: ByteArray +) { + data class Prekey(val id: Long, val publicKey: ByteArray) { + init { + require(id in 0..0xFFFF_FFFFL) + require(publicKey.size == KEY_LENGTH) + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey)) + + override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode() + } + + fun signableBytes(): ByteArray { + val output = ByteArrayOutputStream( + 1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES + ) + output.write(SIGNING_CONTEXT.size) + output.write(SIGNING_CONTEXT) + output.write(fixedKey(noiseStaticPublicKey)) + output.write(prekeys.size.coerceAtMost(0xFF)) + prekeys.take(0xFF).forEach { prekey -> + output.write(uint32Bytes(prekey.id)) + output.write(fixedKey(prekey.publicKey)) + } + output.write(uint64Bytes(generatedAt)) + return output.toByteArray() + } + + fun encode(): ByteArray? { + if (noiseStaticPublicKey.size != KEY_LENGTH || + signature.size != SIGNATURE_LENGTH || + prekeys.isEmpty() || + prekeys.size > MAX_PREKEYS || + prekeys.map { it.id }.distinct().size != prekeys.size + ) { + return null + } + + val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH) + prekeys.forEach { prekey -> + if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null + entries.write(uint32Bytes(prekey.id)) + entries.write(prekey.publicKey) + } + + return Tlv16Codec.encode( + Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey), + Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()), + Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)), + Tlv16Codec.Field(TLV_SIGNATURE, signature) + ) + } + + companion object { + const val KEY_LENGTH = 32 + const val SIGNATURE_LENGTH = 64 + const val MAX_PREKEYS = 8 + private const val PREKEY_ENTRY_LENGTH = 4 + KEY_LENGTH + + private val SIGNING_CONTEXT = "bitchat-prekey-bundle-v1".toByteArray(Charsets.UTF_8) + private const val TLV_NOISE_STATIC_KEY = 0x01 + private const val TLV_PREKEYS = 0x02 + private const val TLV_GENERATED_AT = 0x03 + private const val TLV_SIGNATURE = 0x04 + + fun decode(data: ByteArray): PrekeyBundle? { + var noiseStaticKey: ByteArray? = null + var prekeys: List? = null + var generatedAt: Long? = null + var signature: ByteArray? = null + + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { + TLV_NOISE_STATIC_KEY -> { + if (field.value.size != KEY_LENGTH) return null + noiseStaticKey = field.value + } + TLV_PREKEYS -> { + if (field.value.isEmpty() || + field.value.size % PREKEY_ENTRY_LENGTH != 0 || + field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS + ) { + return null + } + val parsed = mutableListOf() + var entryOffset = 0 + while (entryOffset < field.value.size) { + val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .int.toLong() and 0xFFFF_FFFFL + entryOffset += Int.SIZE_BYTES + val publicKey = + field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH) + entryOffset += KEY_LENGTH + parsed += Prekey(id, publicKey) + } + if (parsed.map { it.id }.distinct().size != parsed.size) return null + prekeys = parsed + } + TLV_GENERATED_AT -> { + if (field.value.size != Long.SIZE_BYTES) return null + generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long + } + TLV_SIGNATURE -> { + if (field.value.size != SIGNATURE_LENGTH) return null + signature = field.value + } + } + } ?: return null + + return runCatching { + PrekeyBundle( + noiseStaticPublicKey = noiseStaticKey ?: return null, + prekeys = prekeys?.takeIf { it.isNotEmpty() } ?: return null, + generatedAt = generatedAt ?: return null, + signature = signature ?: return null + ) + }.getOrNull() + } + + private fun uint32Bytes(value: Long): ByteArray = + ByteBuffer.allocate(Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putInt(value.toInt()) + .array() + + private fun uint64Bytes(value: Long): ByteArray = + ByteBuffer.allocate(Long.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putLong(value) + .array() + + private fun fixedKey(key: ByteArray): ByteArray = + key.copyOf(KEY_LENGTH) + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is PrekeyBundle && + noiseStaticPublicKey.contentEquals(other.noiseStaticPublicKey) && + prekeys == other.prekeys && + generatedAt == other.generatedAt && + signature.contentEquals(other.signature)) + + override fun hashCode(): Int { + var result = noiseStaticPublicKey.contentHashCode() + result = 31 * result + prekeys.hashCode() + result = 31 * result + generatedAt.hashCode() + result = 31 * result + signature.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt b/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt new file mode 100644 index 00000000..4cc2783e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt @@ -0,0 +1,47 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream + +/** + * Minimal unsigned 16-bit big-endian TLV codec used by bridge wire models. + * + * Semantic validation intentionally remains in each model. This helper only + * owns framing so every decoder rejects truncated and trailing data the same + * way. + */ +internal object Tlv16Codec { + data class Field(val type: Int, val value: ByteArray) + + fun encode(vararg fields: Field): ByteArray? { + val output = ByteArrayOutputStream( + fields.sumOf { HEADER_SIZE + it.value.size } + ) + fields.forEach { field -> + if (field.type !in 0..0xFF || field.value.size > 0xFFFF) return null + output.write(field.type) + output.write((field.value.size ushr 8) and 0xFF) + output.write(field.value.size and 0xFF) + output.write(field.value) + } + return output.toByteArray() + } + + fun decode(data: ByteArray): List? { + val fields = mutableListOf() + var offset = 0 + while (offset < data.size) { + if (data.size - offset < HEADER_SIZE) return null + val type = data[offset].toInt() and 0xFF + val length = + ((data[offset + 1].toInt() and 0xFF) shl 8) or + (data[offset + 2].toInt() and 0xFF) + offset += HEADER_SIZE + if (length > data.size - offset) return null + fields += Field(type, data.copyOfRange(offset, offset + length)) + offset += length + } + return fields + } + + private const val HEADER_SIZE = 3 +} diff --git a/app/src/main/java/com/bitchat/android/noise/CourierNoiseCrypto.kt b/app/src/main/java/com/bitchat/android/noise/CourierNoiseCrypto.kt new file mode 100644 index 00000000..f734cccc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/noise/CourierNoiseCrypto.kt @@ -0,0 +1,121 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.HandshakeState +import com.bitchat.android.noise.southernstorm.protocol.Noise + +/** + * One-message Noise X helper used by iOS-compatible courier envelopes. + * + * Protocol: Noise_X_25519_ChaChaPoly_SHA256 + * Prologue: "bitchat-courier-v1" + */ +object CourierNoiseCrypto { + private const val PROTOCOL_NAME = "Noise_X_25519_ChaChaPoly_SHA256" + private val COURIER_PROLOGUE = "bitchat-courier-v1".toByteArray(Charsets.UTF_8) + private val PREKEY_PROLOGUE_PREFIX = "bitchat-prekey-v1".toByteArray(Charsets.UTF_8) + private const val X_OVERHEAD_BYTES = 32 + 48 + 16 + + data class Opened(val payload: ByteArray, val senderStaticKey: ByteArray) + + fun seal( + payload: ByteArray, + senderStaticPrivateKey: ByteArray, + recipientStaticPublicKey: ByteArray + ): ByteArray = sealWithPrologue( + payload, + senderStaticPrivateKey, + recipientStaticPublicKey, + COURIER_PROLOGUE + ) + + fun sealToPrekey( + payload: ByteArray, + senderStaticPrivateKey: ByteArray, + recipientPrekey: com.bitchat.android.model.PrekeyBundle.Prekey + ): ByteArray = sealWithPrologue( + payload, + senderStaticPrivateKey, + recipientPrekey.publicKey, + prekeyPrologue(recipientPrekey.id) + ) + + private fun sealWithPrologue( + payload: ByteArray, + senderStaticPrivateKey: ByteArray, + recipientStaticPublicKey: ByteArray, + prologue: ByteArray + ): ByteArray { + require(senderStaticPrivateKey.size == 32) + require(recipientStaticPublicKey.size == 32) + val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.INITIATOR) + return try { + handshake.setPrologue(prologue, 0, prologue.size) + handshake.getLocalKeyPair().setPrivateKey(senderStaticPrivateKey, 0) + handshake.getRemotePublicKey().setPublicKey(recipientStaticPublicKey, 0) + handshake.start() + val message = ByteArray(payload.size + X_OVERHEAD_BYTES) + val length = handshake.writeMessage(message, 0, payload, 0, payload.size) + message.copyOf(length) + } finally { + handshake.destroy() + } + } + + fun open( + ciphertext: ByteArray, + recipientStaticPrivateKey: ByteArray + ): Opened = openWithPrologue(ciphertext, recipientStaticPrivateKey, COURIER_PROLOGUE) + + fun openWithPrekey( + ciphertext: ByteArray, + recipientPrekeyPrivateKey: ByteArray, + prekeyId: Long + ): Opened = openWithPrologue( + ciphertext, + recipientPrekeyPrivateKey, + prekeyPrologue(prekeyId) + ) + + private fun openWithPrologue( + ciphertext: ByteArray, + recipientStaticPrivateKey: ByteArray, + prologue: ByteArray + ): Opened { + require(recipientStaticPrivateKey.size == 32) + val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.RESPONDER) + return try { + handshake.setPrologue(prologue, 0, prologue.size) + handshake.getLocalKeyPair().setPrivateKey(recipientStaticPrivateKey, 0) + handshake.start() + val payload = ByteArray(ciphertext.size) + val length = handshake.readMessage(ciphertext, 0, ciphertext.size, payload, 0) + val senderKey = ByteArray(handshake.getRemotePublicKey().publicKeyLength) + handshake.getRemotePublicKey().getPublicKey(senderKey, 0) + Opened(payload.copyOf(length), senderKey) + } finally { + handshake.destroy() + } + } + + /** Test/support helper that derives the X25519 public key used on the wire. */ + fun publicKey(privateKey: ByteArray): ByteArray { + require(privateKey.size == 32) + val key = Noise.createDH("25519") + return try { + key.setPrivateKey(privateKey, 0) + ByteArray(key.publicKeyLength).also { key.getPublicKey(it, 0) } + } finally { + key.destroy() + } + } + + private fun prekeyPrologue(prekeyId: Long): ByteArray { + require(prekeyId in 0..0xFFFF_FFFFL) + return PREKEY_PROLOGUE_PREFIX + byteArrayOf( + (prekeyId ushr 24).toByte(), + (prekeyId ushr 16).toByte(), + (prekeyId ushr 8).toByte(), + prekeyId.toByte() + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt index d3ca9208..c7120f5b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt @@ -43,6 +43,9 @@ internal interface BridgeCourierCipher { fun staticPublicKey(): ByteArray? fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray + + fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair = + seal(payload, recipientNoiseKey) to null } private class NostrBridgeCourierRelay( @@ -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 { + 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 = 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 diff --git a/app/src/main/java/com/bitchat/android/nostr/MeshMessageIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/MeshMessageIdentity.kt new file mode 100644 index 00000000..553da1de --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/MeshMessageIdentity.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.nostr + +import java.security.MessageDigest + +/** Cross-platform stable identity for a public mesh radio/bridge copy. */ +object MeshMessageIdentity { + fun stableId(senderIdHex: String, timestampMs: Long, content: String): String { + val input = "${senderIdHex.lowercase()}|$timestampMs|${content.trim()}" + return MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(32) + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index df67822f..ba7ebac0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -69,6 +69,28 @@ data class NostrFilter( limit = limit ) } + + fun bridgeRendezvous( + cells: Collection, + since: Long? = null, + limit: Int = 200 + ): NostrFilter = NostrFilter( + kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("r" to cells.toList()), + limit = limit + ) + + fun courierDrops( + recipientTagsHex: Collection, + since: Long? = null, + limit: Int = 100 + ): NostrFilter = NostrFilter( + kinds = listOf(NostrKind.COURIER_DROP), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("x" to recipientTagsHex.toList()), + limit = limit + ) /** * Create filter for text notes from specific authors diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 665bfd50..ebe32e74 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -176,6 +176,39 @@ object NostrIdentityBridge { Log.d(TAG, "Used fallback geohash identity derivation") return fallbackIdentity } + + /** + * Derive the iOS-compatible bridge rendezvous identity. The domain label + * prevents linking it to geohash-chat identity and the iteration is + * encoded big-endian, matching CryptoKit's UInt32.bigEndian bytes. + */ + fun deriveBridgeIdentity(cell: String, context: Context): NostrIdentity { + val label = "bridge|$cell" + geohashIdentityCache[label]?.let { return it } + val stateManager = SecureIdentityStateManager(context) + val seed = getOrCreateDeviceSeed(stateManager) + val message = label.toByteArray(Charsets.UTF_8) + + for (iteration in 0 until 10) { + val input = message + byteArrayOf( + (iteration ushr 24).toByte(), + (iteration ushr 16).toByte(), + (iteration ushr 8).toByte(), + iteration.toByte() + ) + val candidate = hmacSha256(seed, input).toHexStringLocal() + if (NostrCrypto.isValidPrivateKey(candidate)) { + return NostrIdentity.fromPrivateKey(candidate).also { + geohashIdentityCache[label] = it + } + } + } + + val fallback = MessageDigest.getInstance("SHA-256").digest(seed + message) + return NostrIdentity.fromPrivateKey(fallback.toHexStringLocal()).also { + geohashIdentityCache[label] = it + } + } /** * Generate candidate key for a specific iteration (matches iOS implementation) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 965d8f90..bf33407c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -65,6 +65,12 @@ object NostrProtocol { Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...") return try { + val recipientTags = listOf(listOf("p", recipientIdentity.publicKeyHex)) + if (giftWrap.content.toByteArray(Charsets.UTF_8).size > 64 * 1024 || + giftWrap.kind != NostrKind.GIFT_WRAP || giftWrap.tags != recipientTags || + !NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt) || + !giftWrap.isValidSignature() + ) return null // 1. Unwrap the gift wrap val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex) ?: run { @@ -74,7 +80,7 @@ object NostrProtocol { Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...") - if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) { + if (seal.kind != NostrKind.SEAL || seal.tags.isNotEmpty() || !seal.isValidSignature()) { Log.w(TAG, "❌ Invalid NIP-17 seal signature") return null } @@ -86,7 +92,10 @@ object NostrProtocol { return null } - if (seal.pubkey != rumor.pubkey) { + if (seal.pubkey != rumor.pubkey || rumor.kind != NostrKind.DIRECT_MESSAGE || + (rumor.tags.isNotEmpty() && rumor.tags != recipientTags) || rumor.sig != null || + !NostrTimestampPolicy.isPlausibleRumorTimestamp(rumor.createdAt) + ) { Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey") return null } @@ -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>(listOf("r", cell)) + nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) } + val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() } + if (sender != null && meshTimestampMs != null) { + tags += listOf( + "m", + MeshMessageIdentity.stableId(sender, meshTimestampMs, content), + sender, + meshTimestampMs.toString() + ) + } + return senderIdentity.signEvent( + NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = tags, + content = content + ) + ) + } + + /** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */ + fun createBridgePresenceEvent( + cell: String, + senderIdentity: NostrIdentity + ): NostrEvent = senderIdentity.signEvent( + NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.GEOHASH_PRESENCE, + tags = listOf(listOf("r", cell)), + content = "" + ) + ) + + /** + * Opaque relay drop. Callers use a throwaway identity so deposits cannot + * be linked by their Nostr publisher key. + */ + fun createCourierDropEvent( + envelope: ByteArray, + recipientTagHex: String, + expiresAtMs: Long, + senderIdentity: NostrIdentity + ): NostrEvent = senderIdentity.signEvent( + NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.COURIER_DROP, + tags = listOf( + listOf("x", recipientTagHex), + listOf("expiration", (expiresAtMs / 1000).toString()) + ), + content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP) + ) + ) // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 1024f244..45612907 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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? { diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 4f4857f8..ebcaac69 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -188,6 +188,11 @@ object AppStateStore { fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { + if (!msg.isBridged) { + _publicMessages.value = _publicMessages.value.filterNot { + it.isBridged && it.bridgeRadioMessageIdHint == msg.id + } + } val publicKey = publicMessageKey(msg) if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return seenMessageIds.add(msg.id) @@ -196,6 +201,10 @@ object AppStateStore { } } + fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) { + _publicMessages.value.any { !it.isBridged && it.id == messageId } + } + /** Replace a live media row by ID, or append it if the row was not admitted yet. */ fun upsertPublicMessage(msg: BitchatMessage) { synchronized(this) { diff --git a/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt b/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt new file mode 100644 index 00000000..12365c50 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt @@ -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, + val accepted: Boolean + ) + + private val seenKeys = mutableSetOf() + + fun reconcile( + existing: List, + 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") + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt new file mode 100644 index 00000000..c0a0610f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt @@ -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() + + 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 = 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 { + val type = object : TypeToken>() {}.type + val decoded: Map = runCatching { + preferences.getString(key, null) + ?.let { json -> gson.fromJson>(json, type) } + }.getOrNull() ?: emptyMap() + return LinkedHashMap(decoded) + } + + private fun persist() { + preferences.edit { putString(key, gson.toJson(values)) } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt new file mode 100644 index 00000000..60323cc5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt @@ -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 = 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 +) diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt new file mode 100644 index 00000000..dcc1f668 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgePacketSignatureVerifier.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.protocol.BitchatPacket +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer + +/** + * Verifies bridge protocol packets against the signing key bound by the + * sender's authenticated announcement. + */ +internal object BridgePacketSignatureVerifier { + fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean { + val signature = packet.signature?.takeIf { it.size == 64 } ?: return false + val signingData = packet.toBinaryDataForSigning() ?: return false + if (publicKey.size != 32) return false + return runCatching { + Ed25519Signer().apply { + init(false, Ed25519PublicKeyParameters(publicKey, 0)) + update(signingData, 0, signingData.size) + }.verifySignature(signature) + }.getOrDefault(false) + } +} + +internal object CourierDepositAuthenticator { + fun authenticate( + packet: BitchatPacket, + fromPeerId: String, + directIngress: Boolean, + peers: List, + isTrusted: (VerifiedBridgePeer) -> Boolean + ): VerifiedBridgePeer? { + if (!directIngress || packet.senderID.toHex() != fromPeerId) return null + val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null + if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null + return peer.takeIf(isTrusted) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt new file mode 100644 index 00000000..247d6a34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt @@ -0,0 +1,46 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.MeshPacketUtils +import com.bitchat.android.mesh.BridgeMeshPort +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType + +/** + * Shared construction policy for bridge protocol packets emitted by BLE and + * Wi-Fi Aware mesh implementations. + */ +internal object BridgeProtocolPacketFactory { + fun protocolPacket( + type: MessageType, + payload: ByteArray, + senderPeerId: String, + recipientPeerId: String?, + ttl: UByte, + nowMs: Long = System.currentTimeMillis() + ): BitchatPacket? { + if (payload.isEmpty()) return null + return BitchatPacket( + version = if (payload.size > 0xFFFF) 2u else 1u, + type = type.value, + senderID = MeshPacketUtils.hexStringToByteArray(senderPeerId), + recipientID = recipientPeerId?.let(MeshPacketUtils::hexStringToByteArray), + timestamp = nowMs.toULong(), + payload = payload, + signature = null, + ttl = ttl + ) + } + + fun identityAnnouncement( + nickname: String, + noiseStaticKey: ByteArray, + signingKey: ByteArray + ): IdentityAnnouncement = + IdentityAnnouncement.forLocalPeer( + nickname, + noiseStaticKey, + signingKey, + BridgeMeshPort.advertisedCell() + ) +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt new file mode 100644 index 00000000..75027555 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt @@ -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 = _isEnabled.asStateFlow() + private val _nearbyOnly = MutableStateFlow(false) + val nearbyOnly: StateFlow = _nearbyOnly.asStateFlow() + private val _activeCell = MutableStateFlow(null) + val activeCell: StateFlow = _activeCell.asStateFlow() + private val _bridgedParticipants = MutableStateFlow>(emptyList()) + val bridgedParticipants: StateFlow> = _bridgedParticipants.asStateFlow() + private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied) + private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous") + + @Volatile + private var appContext: Context? = null + private var relayManager: NostrRelayManager? = null + private var prekeyCoordinator: PrekeyCoordinator? = null + private var prefs: android.content.SharedPreferences? = null + @Volatile + private var meshProvider: () -> MeshService? = { null } + private var clock: () -> Long = System::currentTimeMillis + private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong + private var localLocationCell: String? = null + private var subscribedCells: Set = emptySet() + private val verifiedPeers = linkedMapOf() + private val verifiedPeerSnapshot = AtomicReference>(emptyList()) + private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val rebroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val injectedEventIds = BoundedIdSet(MAX_TRACKED_IDS) + private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS) + private val queuedUplinks = mutableListOf() + private val pendingDownlinks = mutableListOf() + private val participants = linkedMapOf() + private val uplinkTimes = mutableMapOf>() + private val inboundTimes = mutableListOf() + private val inboundTimesBySigner = mutableMapOf>() + private val downlinkTimes = mutableListOf() + private val signatureAttemptTimes = mutableListOf() + private var downlinkJob: Job? = null + private var presenceJob: Job? = null + @Volatile + private var suppressPrekeyBroadcasts = false + @Volatile + private var panicQuiesced = false + + fun initialize( + context: Context, + meshProvider: () -> MeshService? = { + com.bitchat.android.service.MeshServiceHolder.unifiedMeshService + }, + clock: () -> Long = System::currentTimeMillis, + jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong + ) { + if (appContext != null) return + synchronized(this) { + if (appContext != null) return + val application = context.applicationContext + appContext = application + this.meshProvider = meshProvider + this.clock = clock + this.jitter = jitter + relayManager = NostrRelayManager.getInstance(application) + val prekeyManager = PrekeyManager.getInstance(application) + prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val storedEnabled = loadEnabledWithMigration(prefs!!) + _isEnabled.value = storedEnabled && !panicQuiesced + if (panicQuiesced && storedEnabled) { + prefs?.edit { putBoolean(KEY_ENABLED, false) } + } + outboundPolicy.set( + BridgeOutboundPolicy.capture( + enabled = _isEnabled.value, + nearbyOnly = _nearbyOnly.value + ) + ) + PeerCapabilities.setBridgeEnabled(_isEnabled.value) + prekeyCoordinator = PrekeyCoordinator( + manager = prekeyManager, + meshProvider = ::currentMesh, + peersProvider = verifiedPeerSnapshot::get, + clock = clock + ) + + } + + val location = LocationChannelManager.getInstance(context) + scope.launch { + location.availableChannels.collect { channels -> + if (!_isEnabled.value) { + localLocationCell = null + return@collect + } + localLocationCell = channels + .firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD } + ?.geohash + ?.take(CELL_PRECISION) + refreshRendezvous() + } + } + scope.launch { + relayManager?.isConnected?.collect { connected -> + if (connected) { + refreshRendezvous(forceSubscriptions = true) + flushQueuedUplinks() + publishPresence() + } + } + } + scope.launch { + 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() + 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) } + +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt new file mode 100644 index 00000000..38eaf147 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.ContactIdentityResolver + +/** + * Coordinates authenticated prekey packets without owning cryptographic + * persistence. [PrekeyManager] remains the repository/crypto boundary. + */ +internal class PrekeyCoordinator( + private val manager: PrekeyManager, + private val meshProvider: () -> MeshService?, + private val peersProvider: () -> List, + private val clock: () -> Long = System::currentTimeMillis +) { + private val pendingPackets = linkedMapOf() + private var lastBroadcastMs = 0L + + fun handlePacket(packet: BitchatPacket) { + val bundle = PrekeyBundle.decode(packet.payload) ?: return + val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey) + if (owner != packet.senderID.toHex()) return + val peer = peersProvider().firstOrNull { it.peerId == owner } + if (peer == null || !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)) { + if (pendingPackets.size < MAX_PENDING_PACKETS || owner in pendingPackets) { + pendingPackets[owner] = packet + } + return + } + ingestVerified(packet, bundle, peer) + } + + fun handlePeerVerified(peerId: String) { + pendingPackets.remove(peerId)?.let(::handlePacket) + } + + fun broadcast(force: Boolean = false) { + val now = clock() + if (!force && now - lastBroadcastMs < REBROADCAST_INTERVAL_MS) return + val bundle = manager.currentSignedBundle(now) ?: return + val encoded = bundle.encode() ?: return + lastBroadcastMs = now + meshProvider()?.sendPrekeyBundle(encoded) + } + + fun wipe() { + pendingPackets.clear() + lastBroadcastMs = 0L + manager.wipe() + } + + private fun ingestVerified( + packet: BitchatPacket, + bundle: PrekeyBundle, + peer: VerifiedBridgePeer + ) { + if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return + if (manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())) { + com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager?.onPublicPacketSeen(packet) + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private companion object { + const val MAX_PENDING_PACKETS = 64 + const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000 + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt new file mode 100644 index 00000000..39c5ef97 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt @@ -0,0 +1,331 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import android.util.Base64 +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.noise.CourierNoiseCrypto +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.SecureRandom + +/** + * Owns local one-time prekeys and verified peer bundles for courier v2. + * + * Local private keys use EncryptedSharedPreferences through + * [SecureIdentityStateManager]. Peer bundles contain public material only, + * but their consumption assignments are persisted so retries of one message + * never spend additional prekeys. + */ +class PrekeyManager internal constructor( + private val identity: PrekeyIdentity, + private val localStore: LocalPrekeyStore, + private val peerStore: PeerPrekeyStore, + private val randomBytes: () -> ByteArray +) { + data class Sealed( + val ciphertext: ByteArray, + val prekeyId: Long? + ) + + data class Opened( + val payload: ByteArray, + val senderStaticKey: ByteArray, + val consumedPrekey: Boolean + ) + + private val lock = Any() + private var local: LocalPrekeyState? = null + private var peerBundles: MutableMap? = null + + fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) { + val staticKey = identity.staticKey()?.second ?: return@synchronized null + val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null + val state = loadLocalLocked() + replenishLocked(state, nowMs) + val prekeys = state.records + .asSequence() + .filter { it.consumedAt == null } + .sortedBy { it.id } + .mapNotNull { record -> + decode(record.privateKey)?.let { privateKey -> + PrekeyBundle.Prekey(record.id, CourierNoiseCrypto.publicKey(privateKey)) + } + } + .toList() + if (prekeys.isEmpty()) return@synchronized null + + val unsigned = PrekeyBundle( + noiseStaticPublicKey = staticKey, + prekeys = prekeys, + generatedAt = state.generatedAt, + signature = ByteArray(PrekeyBundle.SIGNATURE_LENGTH) + ) + val signature = signEd25519(unsigned.signableBytes(), signingPrivateKey) ?: return@synchronized null + unsigned.copy(signature = signature) + } + + fun verifyAndIngest( + bundle: PrekeyBundle, + expectedNoiseKey: ByteArray, + announceBoundSigningKey: ByteArray, + nowMs: Long = System.currentTimeMillis() + ): Boolean { + if (!bundle.noiseStaticPublicKey.contentEquals(expectedNoiseKey) || + announceBoundSigningKey.size != PrekeyBundle.KEY_LENGTH || + !verifyEd25519(bundle.signature, bundle.signableBytes(), announceBoundSigningKey) + ) { + return false + } + + synchronized(lock) { + val bundles = loadPeerBundlesLocked() + val key = encode(bundle.noiseStaticPublicKey) + val existing = bundles[key] + if (existing != null && existing.generatedAt >= bundle.generatedAt) return false + + val freshIds = bundle.prekeys.map { it.id }.toSet() + bundles[key] = StoredPeerPrekeyBundle( + noiseKey = key, + generatedAt = bundle.generatedAt, + prekeyIds = bundle.prekeys.map { it.id }, + prekeyPublicKeys = bundle.prekeys.map { encode(it.publicKey) }, + usedIds = existing?.usedIds?.filterTo(mutableSetOf()) { it in freshIds } ?: mutableSetOf(), + assignments = existing?.assignments + ?.filterValues { it in freshIds } + ?.toMutableMap() ?: mutableMapOf(), + updatedAt = nowMs + ) + while (bundles.size > MAX_PEERS) { + bundles.minByOrNull { it.value.updatedAt }?.key?.let(bundles::remove) + } + persistPeerBundlesLocked(bundles) + return true + } + } + + fun seal( + payload: ByteArray, + messageId: String, + recipientNoiseKey: ByteArray, + recipientAdvertisesPrekeys: Boolean, + nowMs: Long = System.currentTimeMillis() + ): Sealed { + val senderPrivateKey = identity.staticKey()?.first + ?: throw IllegalStateException("Noise static identity is unavailable") + val assigned = if (recipientAdvertisesPrekeys) { + assignPrekey(messageId, recipientNoiseKey, nowMs) + } else { + null + } + return if (assigned != null) { + Sealed( + CourierNoiseCrypto.sealToPrekey(payload, senderPrivateKey, assigned), + assigned.id + ) + } else { + Sealed( + CourierNoiseCrypto.seal(payload, senderPrivateKey, recipientNoiseKey), + null + ) + } + } + + fun open( + ciphertext: ByteArray, + prekeyId: Long?, + nowMs: Long = System.currentTimeMillis() + ): Opened { + if (prekeyId == null) { + val staticPrivateKey = identity.staticKey()?.first + ?: throw IllegalStateException("Noise static identity is unavailable") + val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey) + return Opened(opened.payload, opened.senderStaticKey, false) + } + + synchronized(lock) { + val state = loadLocalLocked() + pruneLocked(state, nowMs) + val record = state.records.firstOrNull { it.id == prekeyId } + ?: throw IllegalArgumentException("Unknown or expired courier prekey") + val consumedAt = record.consumedAt + if (consumedAt != null && nowMs - consumedAt > CONSUMED_GRACE_MS) { + throw IllegalArgumentException("Courier prekey grace window expired") + } + val privateKey = decode(record.privateKey) + ?: throw IllegalArgumentException("Invalid courier prekey") + val opened = CourierNoiseCrypto.openWithPrekey(ciphertext, privateKey, prekeyId) + val newlyConsumed = record.consumedAt == null + if (newlyConsumed) { + record.consumedAt = nowMs + advanceGeneratedAtLocked(state, nowMs) + replenishLocked(state, nowMs) + persistLocalLocked(state) + } + return Opened(opened.payload, opened.senderStaticKey, newlyConsumed) + } + } + + fun hasUsableBundle( + recipientNoiseKey: ByteArray, + nowMs: Long = System.currentTimeMillis() + ): Boolean = synchronized(lock) { + val bundle = loadPeerBundlesLocked()[encode(recipientNoiseKey)] ?: return@synchronized false + isFresh(bundle, nowMs) && bundle.prekeyIds.any { it !in bundle.usedIds } + } + + fun wipe() = synchronized(lock) { + local = LocalPrekeyState() + peerBundles = mutableMapOf() + localStore.clear() + peerStore.clear() + } + + private fun assignPrekey( + messageId: String, + recipientNoiseKey: ByteArray, + nowMs: Long + ): PrekeyBundle.Prekey? = synchronized(lock) { + val bundles = loadPeerBundlesLocked() + val key = encode(recipientNoiseKey) + val bundle = bundles[key] ?: return@synchronized null + if (!isFresh(bundle, nowMs)) return@synchronized null + + bundle.assignments[messageId]?.let { assigned -> + val index = bundle.prekeyIds.indexOf(assigned) + if (index >= 0) { + return@synchronized decode(bundle.prekeyPublicKeys[index]) + ?.let { PrekeyBundle.Prekey(assigned, it) } + } + } + + val index = bundle.prekeyIds.indices + .filter { bundle.prekeyIds[it] !in bundle.usedIds } + .minByOrNull { bundle.prekeyIds[it] } + ?: return@synchronized null + val id = bundle.prekeyIds[index] + val publicKey = decode(bundle.prekeyPublicKeys[index]) ?: return@synchronized null + bundle.usedIds += id + bundle.assignments[messageId] = id + bundle.updatedAt = nowMs + persistPeerBundlesLocked(bundles) + PrekeyBundle.Prekey(id, publicKey) + } + + private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean { + val beforeRecords = state.records.size + val beforeUnconsumed = state.records.count { it.consumedAt == null } + pruneLocked(state, nowMs) + val unconsumed = state.records.count { it.consumedAt == null } + var changed = unconsumed != beforeUnconsumed + if (unconsumed < REPLENISH_THRESHOLD) { + repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) { + val privateKey = randomBytes() + require(privateKey.size == PrekeyBundle.KEY_LENGTH) + state.records += LocalPrekeyRecord( + id = state.nextId and 0xFFFF_FFFFL, + privateKey = encode(privateKey), + createdAt = nowMs + ) + state.nextId = (state.nextId + 1) and 0xFFFF_FFFFL + } + advanceGeneratedAtLocked(state, nowMs) + changed = true + } + if (changed || state.records.size != beforeRecords) persistLocalLocked(state) + return changed + } + + private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) { + state.records.removeAll { record -> + record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS } + ?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS) + } + } + + private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) { + state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1) + } + + private fun loadLocalLocked(): LocalPrekeyState { + local?.let { return it } + val loaded = localStore.load() + local = loaded + return loaded + } + + private fun persistLocalLocked(state: LocalPrekeyState) { + try { localStore.save(state) } catch (error: Exception) { + local = null + throw error + } + } + + private fun loadPeerBundlesLocked(): MutableMap { + peerBundles?.let { return it } + return peerStore.load().also { peerBundles = it } + } + + private fun persistPeerBundlesLocked(bundles: Map) { + try { peerStore.save(bundles) } catch (error: Exception) { + peerBundles = null + throw error + } + } + + private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean = + nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS + + private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching { + Ed25519Signer().apply { + init(true, Ed25519PrivateKeyParameters(privateKey, 0)) + update(data, 0, data.size) + }.generateSignature() + }.getOrNull() + + private fun verifyEd25519(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = + runCatching { + Ed25519Signer().apply { + init(false, Ed25519PublicKeyParameters(publicKey, 0)) + update(data, 0, data.size) + }.verifySignature(signature) + }.getOrDefault(false) + + private fun encode(data: ByteArray): String = + Base64.encodeToString(data, Base64.NO_WRAP) + + private fun decode(value: String): ByteArray? = + runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull() + + companion object { + private const val PEER_PREFS = "bitchat_prekey_bundles" + private const val REPLENISH_THRESHOLD = 3 + private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000 + private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000 + private const val MAX_BUNDLE_AGE_MS = 7L * 24 * 60 * 60 * 1000 + private const val MAX_PEERS = 200 + + @Volatile + private var instance: PrekeyManager? = null + + fun getInstance(context: Context): PrekeyManager = + instance ?: synchronized(this) { + instance ?: run { + val application = context.applicationContext + val identityState = SecureIdentityStateManager(application) + val random = SecureRandom() + PrekeyManager( + identity = AndroidPrekeyIdentity(identityState), + localStore = SecureLocalPrekeyStore(identityState), + peerStore = SharedPreferencesPeerPrekeyStore( + application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE) + ), + randomBytes = { + ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes) + } + ).also { instance = it } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt new file mode 100644 index 00000000..d62e0e29 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.services.bridge + +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.identity.SecureIdentityStateManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +internal data class LocalPrekeyRecord( + val id: Long, + val privateKey: String, + val createdAt: Long, + var consumedAt: Long? = null +) + +internal data class LocalPrekeyState( + var records: MutableList = mutableListOf(), + var nextId: Long = 0, + var generatedAt: Long = 0 +) + +internal data class StoredPeerPrekeyBundle( + val noiseKey: String, + var generatedAt: Long, + var prekeyIds: List, + var prekeyPublicKeys: List, + var usedIds: MutableSet, + var assignments: MutableMap, + var updatedAt: Long +) + +internal interface PrekeyIdentity { + fun staticKey(): Pair? + fun signingKey(): Pair? +} + +internal interface LocalPrekeyStore { + fun load(): LocalPrekeyState + fun save(state: LocalPrekeyState) + fun clear() +} + +internal interface PeerPrekeyStore { + fun load(): MutableMap + fun save(bundles: Map) + fun clear() +} + +internal class AndroidPrekeyIdentity( + private val state: SecureIdentityStateManager +) : PrekeyIdentity { + override fun staticKey(): Pair? = state.loadStaticKey() + override fun signingKey(): Pair? = state.loadSigningKey() +} + +internal class SecureLocalPrekeyStore( + private val state: SecureIdentityStateManager, + private val gson: Gson = Gson() +) : LocalPrekeyStore { + override fun load(): LocalPrekeyState = + runCatching { + state.getSecureValue(LOCAL_STORE_KEY) + ?.let { gson.fromJson(it, LocalPrekeyState::class.java) } + }.getOrNull() ?: LocalPrekeyState() + + override fun save(state: LocalPrekeyState) { + check(this.state.storeSecureValueSynchronously(LOCAL_STORE_KEY, gson.toJson(state))) { + "Unable to persist courier prekeys" + } + } + + override fun clear() { + check(state.clearSecureValuesSynchronously(LOCAL_STORE_KEY)) + } + + private companion object { + const val TAG = "LocalPrekeyStore" + const val LOCAL_STORE_KEY = "courier_prekeys_v1" + } +} + +internal class SharedPreferencesPeerPrekeyStore( + private val preferences: SharedPreferences, + private val gson: Gson = Gson() +) : PeerPrekeyStore { + override fun load(): MutableMap { + val type = object : TypeToken>() {}.type + val values: List = runCatching { + preferences.getString(PEER_BUNDLES_KEY, null) + ?.let { json -> gson.fromJson>(json, type) } + }.getOrNull() ?: emptyList() + return values + .filter { it.prekeyIds.size == it.prekeyPublicKeys.size } + .associateByTo(mutableMapOf()) { it.noiseKey } + } + + override fun save(bundles: Map) { + check(preferences.edit().putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())).commit()) + } + + override fun clear() { + check(preferences.edit().clear().commit()) + } + + private companion object { + const val PEER_BUNDLES_KEY = "bundles_v1" + } +} diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 9963cc45..c10af63a 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -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 = 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() 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) diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt index 8c1be788..179cd69b 100644 --- a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 54ab0318..f01c071b 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -303,11 +303,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, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index aac5283c..a57b7e3b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -564,6 +564,7 @@ fun NicknameEditor( @Composable fun PeerCounter( connectedPeers: List, + bridgedPeopleCount: Int = 0, joinedChannels: Set, hasUnreadChannels: Map, isConnected: Boolean, @@ -586,8 +587,8 @@ fun PeerCounter( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) - val count = connectedPeers.size - Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) + val count = connectedPeers.size + bridgedPeopleCount + Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) colorScheme.secondary else palette.textTertiary) } } @@ -727,6 +728,7 @@ private fun MainHeader( val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val crowdingMode = headerCrowdingMode(maxWidth) @@ -809,6 +811,7 @@ private fun MainHeader( PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, + bridgedPeopleCount = bridgeUiState.participants.size, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, isConnected = isConnected, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index f6c21ef8..b7abfaa6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -83,6 +83,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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index e2989b54..16f5e409 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val bridgeUiState: StateFlow = combine( + MeshBridgeService.isEnabled, + MeshBridgeService.nearbyOnly, + MeshBridgeService.bridgedParticipants + ) { enabled, nearbyOnly, participants -> + BridgeUiState(enabled, nearbyOnly, participants) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = BridgeUiState( + enabled = MeshBridgeService.isEnabled.value, + nearbyOnly = MeshBridgeService.nearbyOnly.value, + participants = MeshBridgeService.bridgedParticipants.value + ) + ) val meshServiceFacade: MeshService get() = mesh val myPeerID: String @@ -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() diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 8517ec15..166cee76 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -14,9 +14,21 @@ class DataManager(private val context: Context) { companion object { private const val TAG = "DataManager" + private const val PREFS_NAME = "bitchat_prefs" + private const val BLOCKED_USERS_KEY = "blocked_users" + + /** + * Reads the current persisted block list for ingress paths that have a + * full Noise key but no live mesh peer/session. + */ + fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getStringSet(BLOCKED_USERS_KEY, emptySet()) + ?.contains(fingerprint) == true } - - private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val gson = Gson() // Channel-related maps that need to persist state @@ -200,13 +212,13 @@ class DataManager(private val context: Context) { @Synchronized fun loadBlockedUsers() { - val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet() _blockedUsers.clear() _blockedUsers.addAll(savedBlockedUsers) } fun saveBlockedUsers() { - prefs.edit().putStringSet("blocked_users", _blockedUsers).apply() + prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply() } fun addBlockedUser(fingerprint: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 1269fe6b..68b17854 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -320,7 +320,10 @@ fun MessageInput( mentionPeerIdentities: Map = emptyMap(), recorderFactory: ((String?, String?) -> VoiceRecorder)? = null, activePublicTalker: String? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showBridgeControls: Boolean = false, + nearbyOnly: Boolean = false, + onNearbyOnlyChange: (Boolean) -> Unit = {} ) { val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme @@ -405,6 +408,25 @@ fun MessageInput( modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.Bottom ) { + if (showBridgeControls) { + IconToggleButton( + checked = nearbyOnly, + onCheckedChange = onNearbyOnlyChange, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (nearbyOnly) Icons.Filled.Bluetooth else Icons.Filled.Public, + contentDescription = if (nearbyOnly) { + stringResource(R.string.cd_nearby_only_on) + } else { + stringResource(R.string.cd_nearby_only_off) + }, + tint = if (nearbyOnly) Color(0xFFFF9500) else Color(0xFF00A7C4), + modifier = Modifier.size(18.dp) + ) + } + } + // MARK: - The pill. Field and action buttons are one visual object. Row( modifier = Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index c6f7eb49..b52937f6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -160,6 +160,8 @@ class MeshDelegateHandler( override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { coroutineScope.launch { + com.bitchat.android.services.MessageRouter.tryGetInstance() + ?.onMessageAcknowledged(messageID, recipientPeerID) messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date())) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index ec0ce080..6411f467 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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, + 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, diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 21cb8fc9..69ff3835 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 049c94db..82376531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -611,6 +611,13 @@ Drag to spin · Pinch to zoom · Tap to focus Select Type a message… + mesh bridge + share public nearby messages through relays and carry encrypted offline messages for others + across the bridge + via mesh bridge + Mesh bridge active + Nearby only: messages stay within radio range + Bridged: messages also reach people across the bridge @%1$s Mention %1$d / %2$d diff --git a/app/src/test/kotlin/com/bitchat/android/model/BridgeProtocolInteropTest.kt b/app/src/test/kotlin/com/bitchat/android/model/BridgeProtocolInteropTest.kt new file mode 100644 index 00000000..5fa39643 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/BridgeProtocolInteropTest.kt @@ -0,0 +1,163 @@ +package com.bitchat.android.model + +import com.bitchat.android.noise.CourierNoiseCrypto +import com.bitchat.android.nostr.MeshMessageIdentity +import com.bitchat.android.nostr.NostrIdentity +import com.bitchat.android.nostr.NostrKind +import com.bitchat.android.nostr.NostrProtocol +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BridgeProtocolInteropTest { + @Test + fun `carrier TLVs match the iOS wire fixture`() { + val carrier = NostrCarrierPacket( + direction = NostrCarrierPacket.Direction.TO_BRIDGE, + geohash = "u4pruy", + eventJson = "{}".toByteArray() + ) + + assertEquals( + "010001030200067534707275790300027b7d", + carrier.encode().toHex() + ) + assertEquals(carrier, NostrCarrierPacket.decode(carrier.encode())) + assertNull(NostrCarrierPacket.decode(carrier.encode().dropLast(1).toByteArray())) + } + + @Test + fun `carrier decoder skips unknown TLVs`() { + val encoded = NostrCarrierPacket( + NostrCarrierPacket.Direction.FROM_BRIDGE, + "u4pruy", + "{}".toByteArray() + ).encode() + val withUnknown = encoded + byteArrayOf(0x7F, 0x00, 0x02, 0x12, 0x34) + + assertEquals( + NostrCarrierPacket.Direction.FROM_BRIDGE, + NostrCarrierPacket.decode(withUnknown)?.direction + ) + } + + @Test + fun `courier envelope and daily tag match iOS fixtures`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 0x0102_0304_0506_0708uL, + ciphertext = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), + copies = 3u, + prekeyID = 0x89AB_CDEFu + ) + + assertEquals( + "010010000102030405060708090a0b0c0d0e0f" + + "0200080102030405060708" + + "030002aabb04000103" + + "05000489abcdef", + envelope.encode()!!.toHex() + ) + assertEquals(envelope, CourierEnvelope.decode(envelope.encode()!!)) + assertEquals( + "f7b87836e588a2b31b306605b3313744", + CourierEnvelope.recipientTag(ByteArray(32) { it.toByte() }, 1u).toHex() + ) + } + + @Test + fun `signed prekey canonical bytes match iOS fixture`() { + val bundle = PrekeyBundle( + noiseStaticPublicKey = ByteArray(32) { 0x11 }, + prekeys = listOf( + PrekeyBundle.Prekey(0x0102_0304, ByteArray(32) { 0x22 }) + ), + generatedAt = 0x0102_0304_0506_0708L, + signature = ByteArray(64) { 0x33 } + ) + + assertEquals( + "18" + + "626974636861742d7072656b65792d62756e646c652d7631" + + "11".repeat(32) + + "01" + + "01020304" + + "22".repeat(32) + + "0102030405060708", + bundle.signableBytes().toHex() + ) + assertEquals(bundle, PrekeyBundle.decode(bundle.encode()!!)) + } + + @Test + fun `courier Noise X opens static and one-time prekey ciphertexts`() { + val senderPrivate = ByteArray(32) { (it + 1).toByte() } + val recipientPrivate = ByteArray(32) { (it + 33).toByte() } + val payload = "offline hello".toByteArray() + + val staticCiphertext = CourierNoiseCrypto.seal( + payload, + senderPrivate, + CourierNoiseCrypto.publicKey(recipientPrivate) + ) + val staticOpened = CourierNoiseCrypto.open(staticCiphertext, recipientPrivate) + assertArrayEquals(payload, staticOpened.payload) + assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), staticOpened.senderStaticKey) + + val prekey = PrekeyBundle.Prekey( + id = 0x89AB_CDEFL, + publicKey = CourierNoiseCrypto.publicKey(recipientPrivate) + ) + val prekeyCiphertext = CourierNoiseCrypto.sealToPrekey(payload, senderPrivate, prekey) + val prekeyOpened = CourierNoiseCrypto.openWithPrekey( + prekeyCiphertext, + recipientPrivate, + prekey.id + ) + assertArrayEquals(payload, prekeyOpened.payload) + assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), prekeyOpened.senderStaticKey) + } + + @Test + fun `public message stable identity matches cross-language fixture`() { + val id = MeshMessageIdentity.stableId( + senderIdHex = "0011223344556677", + timestampMs = 1_750_000_000_123, + content = "hello mesh" + ) + + assertEquals("b83f94d81dcdd1b0c0048f6645995dd4", id) + assertTrue(id.all { it in '0'..'9' || it in 'a'..'f' }) + } + + @Test + fun `bridge Nostr event uses signed rendezvous tags`() { + val identity = NostrIdentity.fromPrivateKey("01".padStart(64, '0')) + val event = NostrProtocol.createBridgeMeshEvent( + content = "hello mesh", + cell = "u4pruy", + senderIdentity = identity, + nickname = "alice", + meshSenderId = "0011223344556677", + meshTimestampMs = 1_750_000_000_123 + ) + + assertEquals(NostrKind.EPHEMERAL_EVENT, event.kind) + assertEquals(listOf("r", "u4pruy"), event.tags[0]) + assertEquals(listOf("n", "alice"), event.tags[1]) + assertEquals( + listOf( + "m", + "b83f94d81dcdd1b0c0048f6645995dd4", + "0011223344556677", + "1750000000123" + ), + event.tags[2] + ) + assertTrue(event.isValidSignature()) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index f7d53285..911c4cba 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -67,6 +67,18 @@ class IdentityAnnouncementTest { assertEquals(PeerCapabilities.NONE, decoded.capabilities) } + @Test + fun `oversized bridge cell is omitted without dropping announcement`() { + val encoded = IdentityAnnouncement( + nickname, + noiseKey, + signingKey, + bridgeGeohash = "u".repeat(13) + ).encode()!! + + assertNull(IdentityAnnouncement.decode(encoded)?.bridgeGeohash) + } + @Test fun `unknown capability bits and TLVs survive decode and re-encode`() { val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! @@ -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)) } } diff --git a/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt b/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt new file mode 100644 index 00000000..0ca771c8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class Tlv16CodecTest { + @Test + fun `codec preserves ordered fields and unknown types`() { + val encoded = requireNotNull( + Tlv16Codec.encode( + Tlv16Codec.Field(1, byteArrayOf(1, 2)), + Tlv16Codec.Field(0x7F, byteArrayOf(3)) + ) + ) + + val decoded = requireNotNull(Tlv16Codec.decode(encoded)) + assertEquals(listOf(1, 0x7F), decoded.map { it.type }) + assertArrayEquals(byteArrayOf(1, 2), decoded[0].value) + assertArrayEquals(byteArrayOf(3), decoded[1].value) + } + + @Test + fun `codec rejects truncated framing and oversized values`() { + assertNull(Tlv16Codec.decode(byteArrayOf(1, 0))) + assertNull(Tlv16Codec.decode(byteArrayOf(1, 0, 2, 1))) + assertNull(Tlv16Codec.encode(Tlv16Codec.Field(1, ByteArray(0x1_0000)))) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index ec3a8679..b25565d9 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -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> = listOf(listOf("p", recipient.publicKeyHex)), + sealTags: List> = emptyList(), + rumorTimestamp: Int = (System.currentTimeMillis() / 1000).toInt(), + outerTags: List> = listOf(listOf("p", recipient.publicKeyHex)) ): NostrEvent { val rumorBase = NostrEvent( pubkey = claimedSender.publicKeyHex, - createdAt = (System.currentTimeMillis() / 1000).toInt(), - kind = NostrKind.DIRECT_MESSAGE, - tags = listOf(listOf("p", recipient.publicKeyHex)), + createdAt = rumorTimestamp, + kind = rumorKind, + tags = rumorTags, content = content ) val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) @@ -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) } diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/BridgeRaceRegressionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/BridgeRaceRegressionTest.kt new file mode 100644 index 00000000..338437f4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/BridgeRaceRegressionTest.kt @@ -0,0 +1,57 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.BridgeOutboundPolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BridgeRaceRegressionTest { + @Test + fun `later opt in cannot authorize a send that was previously denied`() { + val policyAtSend = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false) + val policyAtPublish = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false) + + assertFalse(policyAtSend.permitsPublication(policyAtPublish)) + } + + @Test + fun `later opt out still blocks a send that was previously allowed`() { + val policyAtSend = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false) + val policyAtPublish = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false) + + assertFalse(policyAtSend.permitsPublication(policyAtPublish)) + } + + @Test + fun `publication requires bridge permission at send and publish time`() { + val allowed = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false) + val nearbyOnly = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = true) + + assertTrue(allowed.permitsPublication(allowed)) + assertFalse(nearbyOnly.permitsPublication(allowed)) + assertFalse(allowed.permitsPublication(nearbyOnly)) + } + + @Test + fun `delayed close targets the retired subscription generation`() { + val slot = RelaySubscriptionSlot("bridge") + val activeOnRelay = mutableSetOf() + val delayedCloses = mutableListOf() + + val first = slot.replace( + close = delayedCloses::add, + open = activeOnRelay::add + ) + val second = slot.replace( + close = delayedCloses::add, + open = activeOnRelay::add + ) + + delayedCloses.forEach(activeOnRelay::remove) + + assertNotEquals(first, second) + assertFalse(first in activeOnRelay) + assertTrue(second in activeOnRelay) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt new file mode 100644 index 00000000..c92bc4ef --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt @@ -0,0 +1,129 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.noise.CourierNoiseCrypto +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrekeyManagerTest { + @Test + fun `message retry reuses assigned prekey and recipient grace key`() { + val now = 1_750_000_000_000L + val recipientIdentity = identity(seed = 1) + val senderIdentity = identity(seed = 65) + var generatedSeed = 100 + val recipient = manager(recipientIdentity) { + ByteArray(32) { index -> (generatedSeed + index).toByte() } + .also { generatedSeed += 1 } + } + val sender = manager(senderIdentity) + val bundle = requireNotNull(recipient.currentSignedBundle(now)) + + assertTrue( + sender.verifyAndIngest( + bundle, + expectedNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + announceBoundSigningKey = requireNotNull(recipientIdentity.signingKey()).second, + nowMs = now + ) + ) + + val payload = "offline hello".toByteArray() + val first = sender.seal( + payload, + messageId = "message-1", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + ) + val retry = sender.seal( + payload, + messageId = "message-1", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + 1 + ) + + assertNotNull(first.prekeyId) + assertEquals(first.prekeyId, retry.prekeyId) + val firstOpened = recipient.open(first.ciphertext, first.prekeyId, now + 2) + val retryOpened = recipient.open(retry.ciphertext, retry.prekeyId, now + 3) + assertArrayEquals(payload, firstOpened.payload) + assertArrayEquals(payload, retryOpened.payload) + assertTrue(firstOpened.consumedPrekey) + assertFalse(retryOpened.consumedPrekey) + + val nextMessage = sender.seal( + payload, + messageId = "message-2", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + 4 + ) + assertNotEquals(first.prekeyId, nextMessage.prekeyId) + } + + private fun manager( + identity: PrekeyIdentity, + randomBytes: () -> ByteArray = { ByteArray(32) { (it + 11).toByte() } } + ): PrekeyManager = + PrekeyManager( + identity = identity, + localStore = MemoryLocalPrekeyStore(), + peerStore = MemoryPeerPrekeyStore(), + randomBytes = randomBytes + ) + + private fun identity(seed: Int): PrekeyIdentity { + val staticPrivate = ByteArray(32) { (seed + it).toByte() } + val signingPrivate = Ed25519PrivateKeyParameters( + ByteArray(32) { (seed + 32 + it).toByte() }, + 0 + ) + return FakePrekeyIdentity( + static = staticPrivate to CourierNoiseCrypto.publicKey(staticPrivate), + signing = signingPrivate.encoded to signingPrivate.generatePublicKey().encoded + ) + } + + private class FakePrekeyIdentity( + private val static: Pair, + private val signing: Pair + ) : PrekeyIdentity { + override fun staticKey(): Pair = static + override fun signingKey(): Pair = signing + } + + private class MemoryLocalPrekeyStore : LocalPrekeyStore { + private var state = LocalPrekeyState() + + override fun load(): LocalPrekeyState = state + + override fun save(state: LocalPrekeyState) { + this.state = state + } + + override fun clear() { + state = LocalPrekeyState() + } + } + + private class MemoryPeerPrekeyStore : PeerPrekeyStore { + private var bundles = mutableMapOf() + + override fun load(): MutableMap = bundles + + override fun save(bundles: Map) { + this.bundles = bundles.toMutableMap() + } + + override fun clear() { + bundles.clear() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt index c73cba0d..e59e9e5b 100644 --- a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt @@ -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 ) diff --git a/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt index 263dc712..f4aa084c 100644 --- a/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsTest.kt @@ -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()) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index fd814ba9..ca16d5a0 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -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.