diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 282f3295..5cdce8a7 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -56,6 +56,12 @@ 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) + } 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/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 9a6dff60..a7805534 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -64,7 +64,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic store = authenticatedPeerStateStore, localStateProvider = { AuthenticatedPeerState( - PeerCapabilities.LOCAL_SUPPORTED, + PeerCapabilities.localSupported(), requireNotNull(encryptionService.getSigningPublicKey()) ) }, @@ -904,6 +904,53 @@ 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() + com.bitchat.android.services.bridge.MeshBridgeService.bridgeOutgoing( + content, + myPeerID, + packet.timestamp.toLong(), + nickname + ) + } + } + } + + 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 = false) + } + + 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 = BitchatPacket( + version = if (payload.size > 0xFFFF) 2u else 1u, + type = type.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = recipientPeerID?.let(::hexStringToByteArray), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet + if (sign && outgoing.signature?.size != 64) return@launch + broadcastRoutedPacket(RoutedPacket(outgoing)) } } @@ -1239,7 +1286,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer( + nickname, + staticKey, + signingKey, + com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + ) var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") @@ -1302,7 +1354,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer( + nickname, + staticKey, + signingKey, + com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + ) var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") 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 375e6531..fc2458ce 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -62,7 +62,7 @@ class MeshCore( store = authenticatedPeerStateStore, localStateProvider = { AuthenticatedPeerState( - PeerCapabilities.LOCAL_SUPPORTED, + PeerCapabilities.localSupported(), requireNotNull(encryptionService.getSigningPublicKey()) ) }, @@ -524,6 +524,67 @@ 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() + com.bitchat.android.services.bridge.MeshBridgeService.bridgeOutgoing( + content, + myPeerID, + packet.timestamp.toLong(), + nickname + ) + } + } + } + + 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 = false + ) + } + + 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 = BitchatPacket( + version = if (payload.size > 0xFFFF) 2u else 1u, + type = type.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = recipientPeerID?.let(MeshPacketUtils::hexStringToByteArray), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet + if (sign && outgoing.signature?.size != 64) return@launch + dispatchGlobal(RoutedPacket(outgoing)) } } @@ -756,7 +817,12 @@ class MeshCore( Log.e("MeshCore", "No signing public key available for announcement") return@launch } - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer( + nickname, + staticKey, + signingKey, + com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + ) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -777,7 +843,12 @@ class MeshCore( ?: myPeerID val staticKey = encryptionService.getStaticPublicKey() ?: return val signingKey = encryptionService.getSigningPublicKey() ?: return - val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer( + nickname, + staticKey, + signingKey, + com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + ) 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 063fe4f8..f8358c7f 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 beedd857..9ccca43c 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 kotlinx.coroutines.* import java.util.* @@ -318,6 +319,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro capabilities = announcement.capabilities ) ?: false + com.bitchat.android.services.bridge.MeshBridgeService.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) @@ -448,13 +454,19 @@ 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) + com.bitchat.android.services.bridge.MeshBridgeService + .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 0fdae3b0..3037fc12 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -149,12 +149,31 @@ class PacketProcessor(private val myPeerID: String) { MessageType.LEAVE -> handleLeave(routed) MessageType.FRAGMENT -> handleFragment(routed) MessageType.REQUEST_SYNC -> handleRequestSync(routed) + MessageType.PREKEY_BUNDLE -> { + com.bitchat.android.services.bridge.MeshBridgeService.handlePrekeyPacket(packet) + } + MessageType.NOSTR_CARRIER -> { + val directedToUs = packetRelayManager.isPacketAddressedToMe(packet) + val isBroadcast = packet.recipientID == null || + packet.recipientID.contentEquals(delegate?.getBroadcastRecipient()) + if (directedToUs || isBroadcast) { + com.bitchat.android.services.bridge.MeshBridgeService.handleCarrier( + packet.payload, + peerID, + directedToUs + ) + } + } else -> { // Handle private packet types (address check required) if (packetRelayManager.isPacketAddressedToMe(packet)) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) + MessageType.COURIER_ENVELOPE -> { + com.bitchat.android.services.bridge.MeshBridgeService + .handleCourierEnvelope(packet.payload) + } MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false 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 45e668e8..8ab3fdb9 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -274,7 +274,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private MessageType.ANNOUNCE, MessageType.MESSAGE, MessageType.FILE_TRANSFER, - MessageType.LEAVE + MessageType.LEAVE, + 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 ca49def9..e5b22fb4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -64,6 +64,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 8e1731b1..82df418d 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -69,7 +69,15 @@ data class BitchatMessage( val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, - val powDifficulty: Int? = null + val powDifficulty: Int? = 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 { /** @@ -355,4 +363,3 @@ data class BitchatMessage( } } - diff --git a/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt new file mode 100644 index 00000000..b86f6725 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt @@ -0,0 +1,163 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Opaque store-and-forward courier envelope compatible with iOS. + * + * Version 1 envelopes contain a one-way Noise X ciphertext to the recipient's + * static key. A non-null [prekeyId] identifies the forward-secret v2 format. + */ +data class CourierEnvelope( + val recipientTag: ByteArray, + val expiry: Long, + val ciphertext: ByteArray, + val copies: Int = 1, + val prekeyId: Long? = null +) { + val normalizedCopies: Int = copies.coerceIn(1, MAX_COPIES) + + fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean = nowMs >= expiry + + fun encode(): ByteArray? { + if (recipientTag.size != TAG_LENGTH) return null + if (ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES) return null + + val output = ByteArrayOutputStream(ciphertext.size + 40) + appendTlv(output, TLV_RECIPIENT_TAG, recipientTag) + appendTlv( + output, + TLV_EXPIRY, + ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(expiry).array() + ) + appendTlv(output, TLV_CIPHERTEXT, ciphertext) + if (normalizedCopies > 1) { + appendTlv(output, TLV_COPIES, byteArrayOf(normalizedCopies.toByte())) + } + prekeyId?.let { + if (it !in 0..0xFFFF_FFFFL) return null + appendTlv( + output, + TLV_PREKEY_ID, + ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array() + ) + } + return output.toByteArray() + } + + companion object { + const val TAG_LENGTH = 16 + const val MAX_CIPHERTEXT_BYTES = 16 * 1024 + const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L + const val MAX_COPIES = 8 + + private const val TLV_RECIPIENT_TAG = 0x01 + private const val TLV_EXPIRY = 0x02 + private const val TLV_CIPHERTEXT = 0x03 + private const val TLV_COPIES = 0x04 + private const val TLV_PREKEY_ID = 0x05 + private val TAG_CONTEXT = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8) + + fun decode(data: ByteArray): CourierEnvelope? { + var offset = 0 + var recipientTag: ByteArray? = null + var expiry: Long? = null + var ciphertext: ByteArray? = null + var copies = 1 + var prekeyId: Long? = null + + while (offset < data.size) { + if (offset + 3 > data.size) return null + val type = data[offset].toInt() and 0xFF + val length = + ((data[offset + 1].toInt() and 0xFF) shl 8) or + (data[offset + 2].toInt() and 0xFF) + offset += 3 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + TLV_RECIPIENT_TAG -> { + if (length != TAG_LENGTH) return null + recipientTag = value + } + TLV_EXPIRY -> { + if (length != Long.SIZE_BYTES) return null + expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long + } + TLV_CIPHERTEXT -> { + if (length !in 1..MAX_CIPHERTEXT_BYTES) return null + ciphertext = value + } + TLV_COPIES -> { + if (length != 1) return null + copies = value[0].toInt() and 0xFF + } + TLV_PREKEY_ID -> { + if (length != Int.SIZE_BYTES) return null + prekeyId = + ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xFFFF_FFFFL + } + } + } + + return CourierEnvelope( + recipientTag = recipientTag ?: return null, + expiry = expiry ?: return null, + ciphertext = ciphertext ?: return null, + copies = copies, + prekeyId = prekeyId + ) + } + + fun epochDay(nowMs: Long = System.currentTimeMillis()): Long = + (nowMs.coerceAtLeast(0L) / 86_400_000L) and 0xFFFF_FFFFL + + fun recipientTag(noiseStaticKey: ByteArray, epochDay: Long): ByteArray { + require(epochDay in 0..0xFFFF_FFFFL) + val message = TAG_CONTEXT + ByteBuffer.allocate(Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putInt(epochDay.toInt()) + .array() + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256")) + return mac.doFinal(message).copyOf(TAG_LENGTH) + } + + fun candidateTags(noiseStaticKey: ByteArray, aroundMs: Long = System.currentTimeMillis()): List { + val day = epochDay(aroundMs) + return listOf(if (day == 0L) 0L else day - 1, day, (day + 1) and 0xFFFF_FFFFL) + .map { recipientTag(noiseStaticKey, it) } + } + + private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) { + output.write(type) + output.write((value.size ushr 8) and 0xFF) + output.write(value.size and 0xFF) + output.write(value) + } + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is CourierEnvelope && + recipientTag.contentEquals(other.recipientTag) && + expiry == other.expiry && + ciphertext.contentEquals(other.ciphertext) && + normalizedCopies == other.normalizedCopies && + prekeyId == other.prekeyId) + + override fun hashCode(): Int { + var result = recipientTag.contentHashCode() + result = 31 * result + expiry.hashCode() + result = 31 * result + ciphertext.contentHashCode() + result = 31 * result + normalizedCopies + result = 31 * result + (prekeyId?.hashCode() ?: 0) + return result + } +} 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..d6df4fa5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt @@ -0,0 +1,116 @@ +package com.bitchat.android.model + +import com.bitchat.android.nostr.NostrEvent +import java.io.ByteArrayOutputStream + +/** + * 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 { + val output = ByteArrayOutputStream(eventJson.size + geohash.length + 12) + appendTlv(output, TLV_DIRECTION, byteArrayOf(direction.value.toByte())) + appendTlv(output, TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)) + appendTlv(output, TLV_EVENT_JSON, eventJson) + return output.toByteArray() + } + + 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 offset = 0 + var direction: Direction? = null + var geohash: String? = null + var eventJson: ByteArray? = null + + while (offset + 3 <= data.size) { + val type = data[offset].toInt() and 0xFF + val length = + ((data[offset + 1].toInt() and 0xFF) shl 8) or + (data[offset + 2].toInt() and 0xFF) + offset += 3 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + TLV_DIRECTION -> { + if (value.size != 1) return null + direction = Direction.fromValue(value[0].toInt() and 0xFF) ?: return null + } + TLV_GEOHASH -> { + geohash = value.toString(Charsets.UTF_8) + } + TLV_EVENT_JSON -> eventJson = value + } + } + + if (offset != data.size) return null + return runCatching { + NostrCarrierPacket( + direction = direction ?: return null, + geohash = geohash ?: return null, + eventJson = eventJson ?: return null + ) + }.getOrNull() + } + + private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) { + output.write(type) + output.write((value.size ushr 8) and 0xFF) + output.write(value.size and 0xFF) + output.write(value) + } + } + + override fun equals(other: Any?): Boolean = + 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 d53f3d20..f5316ba8 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -32,8 +32,28 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + /** 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 = PRIVATE_MEDIA + @Deprecated("Use localSupported() so runtime bridge state is included") + val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue) + + @Volatile + private var bridgeEnabled: Boolean = false + + fun setBridgeEnabled(enabled: Boolean) { + bridgeEnabled = enabled + } + + fun localSupported(): PeerCapabilities = PeerCapabilities( + PRIVATE_MEDIA.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..92fc9f6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt @@ -0,0 +1,188 @@ +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 ByteArrayOutputStream(128 + entries.size()).apply { + appendTlv(this, TLV_NOISE_STATIC_KEY, noiseStaticPublicKey) + appendTlv(this, TLV_PREKEYS, entries.toByteArray()) + appendTlv(this, TLV_GENERATED_AT, uint64Bytes(generatedAt)) + appendTlv(this, TLV_SIGNATURE, signature) + }.toByteArray() + } + + 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 offset = 0 + var noiseStaticKey: ByteArray? = null + var prekeys: List? = null + var generatedAt: Long? = null + var signature: ByteArray? = null + + while (offset < data.size) { + if (offset + 3 > data.size) return null + val type = data[offset].toInt() and 0xFF + val length = + ((data[offset + 1].toInt() and 0xFF) shl 8) or + (data[offset + 2].toInt() and 0xFF) + offset += 3 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + TLV_NOISE_STATIC_KEY -> { + if (length != KEY_LENGTH) return null + noiseStaticKey = value + } + TLV_PREKEYS -> { + if (length == 0 || + length % PREKEY_ENTRY_LENGTH != 0 || + length / PREKEY_ENTRY_LENGTH > MAX_PREKEYS + ) { + return null + } + val parsed = mutableListOf() + var entryOffset = 0 + while (entryOffset < value.size) { + val id = ByteBuffer.wrap(value, entryOffset, Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .int.toLong() and 0xFFFF_FFFFL + entryOffset += Int.SIZE_BYTES + val publicKey = value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH) + entryOffset += KEY_LENGTH + parsed += Prekey(id, publicKey) + } + if (parsed.map { it.id }.distinct().size != parsed.size) return null + prekeys = parsed + } + TLV_GENERATED_AT -> { + if (length != Long.SIZE_BYTES) return null + generatedAt = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long + } + TLV_SIGNATURE -> { + if (length != SIGNATURE_LENGTH) return null + signature = value + } + } + } + + 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 appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) { + output.write(type) + output.write((value.size ushr 8) and 0xFF) + output.write(value.size and 0xFF) + output.write(value) + } + + private fun uint32Bytes(value: Long): ByteArray = + ByteBuffer.allocate(Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .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/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/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/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 92752b17..b334677f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -214,6 +214,7 @@ object NostrKind { const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned) const val SEAL = 13 // NIP-17 sealed event const val GIFT_WRAP = 1059 // NIP-17 gift wrap + const val COURIER_DROP = 1401 // Opaque store-and-forward envelope const val EPHEMERAL_EVENT = 20000 // For geohash channels const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat } 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 7b8552be..a9ee74ad 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -175,6 +175,39 @@ object NostrIdentityBridge { Log.d(TAG, "Used fallback identity derivation for $forGeohash") 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 501cf60c..71a63706 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -212,6 +212,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 a952d5fa..62574e1d 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -13,11 +13,14 @@ enum class MessageType(val value: UByte) { ANNOUNCE(0x01u), MESSAGE(0x02u), // All user messages (private and broadcast) LEAVE(0x03u), + COURIER_ENVELOPE(0x04u), // Store-and-forward envelope NOISE_HANDSHAKE(0x10u), // Noise handshake NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message FRAGMENT(0x20u), // Fragmentation for large packets REQUEST_SYNC(0x21u), // GCS-based sync request - FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.) + FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.) + 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 c7971eb8..a1217c1d 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -87,6 +87,14 @@ object AppStateStore { fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { + if (!msg.isBridged) { + val filtered = _publicMessages.value.filterNot { + it.isBridged && it.bridgeRadioMessageIdHint == msg.id + } + if (filtered.size != _publicMessages.value.size) { + _publicMessages.value = filtered + } + } val publicKey = publicMessageKey(msg) if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return seenMessageIds.add(msg.id) @@ -95,6 +103,10 @@ object AppStateStore { } } + fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) { + _publicMessages.value.any { !it.isBridged && it.id == messageId } + } + fun addPrivateMessage(peerID: String, msg: BitchatMessage) { synchronized(this) { if (seenMessageIds.contains(msg.id)) return diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 652e0c93..150a7082 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -90,6 +90,17 @@ class MessageRouter private constructor( Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") val q = outbox.getOrPut(conversationID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) + resolution.noisePublicKey?.let { recipientNoiseKey -> + try { + com.bitchat.android.services.bridge.MeshBridgeService.depositCourierDrop( + content = content, + messageId = messageID, + recipientNoiseKey = recipientNoiseKey + ) + } catch (e: Exception) { + Log.w(TAG, "Courier deposit failed: ${e.message}") + } + } Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) } return RouteResult.QUEUED 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..cb2996d5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt @@ -0,0 +1,1004 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.geohash.Geohash +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.NostrCarrierPacket +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.nostr.MeshMessageIdentity +import com.bitchat.android.nostr.NostrEvent +import com.bitchat.android.nostr.NostrFilter +import com.bitchat.android.nostr.NostrIdentity +import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.NostrKind +import com.bitchat.android.nostr.NostrProtocol +import com.bitchat.android.nostr.NostrRelayManager +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.service.MeshServiceHolder +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +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.launch +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.MessageDigest +import java.util.Date +import kotlin.math.abs +import kotlin.random.Random + +/** + * 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 { + data class BridgedParticipant( + val pubkey: String, + val nickname: String?, + val lastSeenMs: Long + ) { + val displayName: String + get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}" + } + + private data class VerifiedPeer( + val peerId: String, + val nickname: String, + val noiseKey: ByteArray, + val signingKey: ByteArray, + val capabilities: PeerCapabilities?, + val bridgeCell: String?, + val lastSeenMs: Long + ) + + private data class PendingUplink( + val depositor: String, + val cell: String, + val event: NostrEvent + ) + + private data class PendingDownlink( + val cell: String, + val event: NostrEvent + ) + + private data class PendingDrop( + val envelope: CourierEnvelope, + val dedupKey: String? + ) + + private const val TAG = "MeshBridgeService" + private const val PREFS = "bitchat_bridge" + private const val KEY_ENABLED = "bridge_enabled_v1" + private const val BRIDGE_SUBSCRIPTION = "mesh-bridge-rendezvous" + private const val COURIER_SUBSCRIPTION = "mesh-bridge-courier" + private const val CELL_PRECISION = 6 + private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000 + private const val MAX_CONTENT_BYTES = 16_000 + 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 const val MAX_WATCHED_COURIER_PEERS = 16 + private const val MAX_PENDING_DROPS = 20 + private const val MAX_DROP_BYTES = 20 * 1024 + private const val PREKEY_REBROADCAST_MS = 60L * 60 * 1000 + private const val DROP_DEDUP_MS = 24L * 60 * 60 * 1000 + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) + private val _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() + + @Volatile + private var appContext: Context? = null + private var relayManager: NostrRelayManager? = null + private var prekeys: PrekeyManager? = null + private var prefs: android.content.SharedPreferences? = null + private var localLocationCell: String? = null + private var subscribedCells: Set = emptySet() + private var subscribedCourierTags: Set = emptySet() + private val verifiedPeers = linkedMapOf() + private val pendingPrekeyPackets = linkedMapOf() + 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 pendingDrops = 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 + private var lastPrekeyBroadcastMs = 0L + private var publishedDropKeys: PersistentExpiringIdSet? = null + private var seenDropEventIds: PersistentExpiringIdSet? = null + private var openedCourierMessageIds: PersistentExpiringIdSet? = null + + fun initialize(context: Context) { + if (appContext != null) return + synchronized(this) { + if (appContext != null) return + val application = context.applicationContext + appContext = application + relayManager = NostrRelayManager.getInstance(application) + prekeys = PrekeyManager.getInstance(application) + prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + _isEnabled.value = loadEnabledWithMigration(prefs!!) + PeerCapabilities.setBridgeEnabled(_isEnabled.value) + publishedDropKeys = PersistentExpiringIdSet(prefs!!, "published_drop_keys", MAX_TRACKED_IDS) + seenDropEventIds = PersistentExpiringIdSet(prefs!!, "seen_drop_events", MAX_TRACKED_IDS) + openedCourierMessageIds = + PersistentExpiringIdSet(prefs!!, "opened_courier_messages", MAX_TRACKED_IDS) + } + + val location = LocationChannelManager.getInstance(context) + scope.launch { + location.availableChannels.collect { channels -> + localLocationCell = channels + .firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD } + ?.geohash + ?.take(CELL_PRECISION) + refreshRendezvous() + } + } + scope.launch { + relayManager?.isConnected?.collect { connected -> + if (connected) { + refreshRendezvous(forceSubscriptions = true) + flushQueuedUplinks() + flushPendingDrops() + publishPresence() + } + } + } + scope.launch { + if (_isEnabled.value) { + relayManager?.connect() + location.refreshChannels() + refreshRendezvous(forceSubscriptions = true) + refreshCourierSubscription() + } + startPresenceLoop() + delay(2_000) + broadcastPrekeyBundle(force = true) + } + } + + fun setEnabled(enabled: Boolean) { + if (_isEnabled.value == enabled) return + _isEnabled.value = enabled + prefs?.edit { putBoolean(KEY_ENABLED, enabled) } + PeerCapabilities.setBridgeEnabled(enabled) + _nearbyOnly.value = false + scope.launch { + if (!enabled) { + closeSubscriptions() + queuedUplinks.clear() + pendingDownlinks.clear() + pendingDrops.clear() + participants.clear() + publishParticipants() + _activeCell.value = null + } else { + relayManager?.connect() + LocationChannelManager.getInstance(requireContext()).refreshChannels() + refreshRendezvous(forceSubscriptions = true) + refreshCourierSubscription() + broadcastPrekeyBundle(force = true) + } + currentMesh()?.sendBroadcastAnnounce() + } + } + + fun setNearbyOnly(enabled: Boolean) { + _nearbyOnly.value = enabled + } + + /** Cell included in announce TLV 0x06 while the bridge switch is on. */ + fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value } + + fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String? + ) { + scope.launch { + if (!_isEnabled.value || _nearbyOnly.value) 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 + ) + 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. */ + fun handleAuthenticatedRadioMessage(messageId: String) { + if (messageId.isBlank()) return + scope.launch { + radioMessageIds.add(messageId) + pendingDownlinks.removeAll { pending -> + classifyMessage(pending.event, pending.cell)?.bridgeRadioMessageIdHint == messageId + } + } + } + + fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) { + scope.launch { + val peer = VerifiedPeer( + peerId = peerId, + nickname = announcement.nickname, + noiseKey = announcement.noisePublicKey.copyOf(), + signingKey = announcement.signingPublicKey.copyOf(), + capabilities = announcement.capabilities, + bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash), + lastSeenMs = System.currentTimeMillis() + ) + verifiedPeers[peerId] = peer + while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first()) + pendingPrekeyPackets.remove(peerId)?.let { ingestPrekeyPacket(it) } + if (_isEnabled.value) { + refreshRendezvous() + refreshCourierSubscription() + } + broadcastPrekeyBundle() + } + } + + fun handlePrekeyPacket(packet: BitchatPacket) { + scope.launch { ingestPrekeyPacket(packet) } + } + + fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { + 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 + } + } + } + + fun handleCourierEnvelope(payload: ByteArray) { + scope.launch { + val envelope = CourierEnvelope.decode(payload) ?: return@launch + if (!validEnvelopeLifetime(envelope)) return@launch + if (isMyCourierTag(envelope.recipientTag)) { + openCourierEnvelope(envelope) + } else if (_isEnabled.value) { + publishOrQueueDrop(envelope, dedupKey = null) + } + } + } + + /** + * Deposit an offline DM either directly to relays or through a reachable + * bridge peer. Returns true when a compatible envelope was produced and + * accepted by one of those paths. + */ + fun depositCourierDrop( + content: String, + messageId: String, + recipientNoiseKey: ByteArray + ): Boolean { + if (!_isEnabled.value || content.toByteArray(Charsets.UTF_8).size > 255) return false + val privatePacket = PrivateMessagePacket(messageId, content).encode() ?: return false + val typedPayload = NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privatePacket).encode() + val livePeer = verifiedPeers.values.firstOrNull { + it.noiseKey.contentEquals(recipientNoiseKey) && + currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true + } + val allowsPrekeys = livePeer?.capabilities?.contains(PeerCapabilities.PREKEYS) != false + val sealed = runCatching { + prekeys?.seal( + typedPayload, + messageId, + recipientNoiseKey, + recipientAdvertisesPrekeys = allowsPrekeys + ) + }.getOrNull() ?: return false + val now = System.currentTimeMillis() + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag( + recipientNoiseKey, + CourierEnvelope.epochDay(now) + ), + expiry = now + CourierEnvelope.MAX_LIFETIME_MS, + ciphertext = sealed.ciphertext, + copies = 1, + prekeyId = sealed.prekeyId + ) + val encoded = envelope.encode() ?: return false + if (encoded.size > MAX_DROP_BYTES) return false + val dedupKey = senderDropKey(messageId, recipientNoiseKey) + if (publishedDropKeys?.contains(dedupKey) == true) return true + + val relayConnected = relayManager?.isConnected?.value == true + if (relayConnected) { + scope.launch { publishOrQueueDrop(envelope, dedupKey) } + return true + } + val gateway = availableBridgePeer() + if (gateway != null) { + currentMesh()?.sendCourierEnvelope(encoded, gateway.peerId) + return true + } + scope.launch { enqueueDrop(PendingDrop(envelope, dedupKey)) } + return true + } + + fun wipe() { + // Clear persistent cryptographic and dedup material immediately. The + // rest of the process-local bridge state remains serialized on scope. + prekeys?.wipe() + publishedDropKeys?.clear() + seenDropEventIds?.clear() + openedCourierMessageIds?.clear() + scope.launch { + closeSubscriptions() + queuedUplinks.clear() + pendingDownlinks.clear() + pendingDrops.clear() + verifiedPeers.clear() + participants.clear() + publishedEventIds.clear() + receivedEventIds.clear() + meshBroadcastEventIds.clear() + rebroadcastEventIds.clear() + injectedEventIds.clear() + radioMessageIds.clear() + _nearbyOnly.value = false + publishParticipants() + } + } + + 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) { + relayManager?.unsubscribe(BRIDGE_SUBSCRIPTION) + subscribedCells = cells + val targets = linkedSetOf() + cells.forEach { subscribedCell -> + relayManager?.ensureGeohashRelaysConnected(subscribedCell) + targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty() + } + relayManager?.subscribe( + filter = NostrFilter.bridgeRendezvous( + cells, + since = System.currentTimeMillis() - MAX_EVENT_AGE_MS + ), + id = BRIDGE_SUBSCRIPTION, + handler = { event -> scope.launch { handleRendezvousEvent(event) } }, + targetRelayUrls = targets.toList() + ) + publishPresence() + } + } + + private fun currentCell(): String? { + localLocationCell?.takeIf(::isValidGeohash)?.let { return it.take(CELL_PRECISION) } + return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION) + } + + private fun availableBridgePeer(): VerifiedPeer? = + 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 = System.currentTimeMillis() + downlinkTimes.removeAll { now - it >= 60_000 } + val waitMs = if (jitter) { + Random.nextLong(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 = System.currentTimeMillis() + 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 += System.currentTimeMillis() + } + 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) { + refreshRendezvous() + refreshCourierSubscription() + publishPresence() + broadcastPrekeyBundle() + } + } + } + } + + private fun recordParticipant(pubkey: String, nickname: String?) { + val now = System.currentTimeMillis() + 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 = System.currentTimeMillis() + 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 = System.currentTimeMillis() + 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 = System.currentTimeMillis() + 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 = System.currentTimeMillis() + signatureAttemptTimes.removeAll { now - it >= 60_000 } + if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false + signatureAttemptTimes += now + return true + } + + private fun ingestPrekeyPacket(packet: BitchatPacket) { + val bundle = PrekeyBundle.decode(packet.payload) ?: return + val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey) + val packetOwner = packet.senderID.toHex() + if (owner != packetOwner) return + val peer = verifiedPeers[owner] + if (peer == null || + !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey) + ) { + if (pendingPrekeyPackets.size < 64 || owner in pendingPrekeyPackets) { + pendingPrekeyPackets[owner] = packet + } + return + } + val signature = packet.signature ?: return + val signingData = packet.toBinaryDataForSigning() ?: return + if (!verifyEd25519(signature, signingData, peer.signingKey)) return + prekeys?.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey) + } + + private fun broadcastPrekeyBundle(force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force && now - lastPrekeyBroadcastMs < PREKEY_REBROADCAST_MS) return + val bundle = prekeys?.currentSignedBundle(now) ?: return + val encoded = bundle.encode() ?: return + lastPrekeyBroadcastMs = now + currentMesh()?.sendPrekeyBundle(encoded) + } + + private fun refreshCourierSubscription() { + if (!_isEnabled.value) return + val identityKey = SecureIdentityStateManager(requireContext()).loadStaticKey()?.second ?: return + val myTags = CourierEnvelope.candidateTags(identityKey).map { it.toHex() }.toSet() + val peerTags = verifiedPeers.values + .asSequence() + .filter { currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true } + .take(MAX_WATCHED_COURIER_PEERS) + .flatMap { + CourierEnvelope.candidateTags(it.noiseKey) + .asSequence() + .map { bytes -> bytes.toHex() } + } + .toSet() + val allTags = myTags + peerTags + if (allTags == subscribedCourierTags) return + relayManager?.unsubscribe(COURIER_SUBSCRIPTION) + subscribedCourierTags = allTags + if (allTags.isEmpty()) return + relayManager?.subscribe( + filter = NostrFilter.courierDrops( + allTags, + since = System.currentTimeMillis() - CourierEnvelope.MAX_LIFETIME_MS + ), + id = COURIER_SUBSCRIPTION, + handler = { event -> scope.launch { handleDropEvent(event) } }, + targetRelayUrls = NostrRelayManager.defaultRelays() + ) + } + + private fun handleDropEvent(event: NostrEvent) { + if (!_isEnabled.value || + event.kind != NostrKind.COURIER_DROP || + seenDropEventIds?.contains(event.id) == true || + !allowSignatureAttempt() || + !event.isValidSignature() + ) { + return + } + val data = runCatching { Base64.decode(event.content, Base64.DEFAULT) }.getOrNull() ?: return + if (data.size > MAX_DROP_BYTES) return + val envelope = CourierEnvelope.decode(data) ?: return + if (!validEnvelopeLifetime(envelope)) return + val tagHex = envelope.recipientTag.toHex() + if (event.tags.none { it.size >= 2 && it[0] == "x" && it[1] == tagHex }) return + + if (isMyCourierTag(envelope.recipientTag)) { + if (openCourierEnvelope(envelope)) { + seenDropEventIds?.add(event.id, DROP_DEDUP_MS) + } + return + } + val peer = verifiedPeers.values + .asSequence() + .filter { currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true } + .take(MAX_WATCHED_COURIER_PEERS) + .firstOrNull { + CourierEnvelope.candidateTags(it.noiseKey) + .any { candidate -> candidate.contentEquals(envelope.recipientTag) } + } + if (peer != null) { + currentMesh()?.sendCourierEnvelope(data, peer.peerId) + seenDropEventIds?.add(event.id, DROP_DEDUP_MS) + } + } + + private fun openCourierEnvelope(envelope: CourierEnvelope): Boolean { + val opened = runCatching { + prekeys?.open(envelope.ciphertext, envelope.prekeyId) + }.getOrNull() ?: return false + val payload = NoisePayload.decode(opened.payload) ?: return true + if (payload.type != NoisePayloadType.PRIVATE_MESSAGE) return true + val privateMessage = PrivateMessagePacket.decode(payload.data) ?: return true + if (openedCourierMessageIds?.contains(privateMessage.messageID) == true) return true + val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey) + val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex()) + val message = BitchatMessage( + id = privateMessage.messageID, + sender = senderResolution.displayName ?: verifiedPeers[senderPeerId]?.nickname ?: "Unknown", + content = privateMessage.content, + timestamp = Date(), + isPrivate = true, + recipientNickname = currentMesh()?.myPeerID, + senderPeerID = senderPeerId + ) + AppStateStore.addPrivateMessage( + ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey), + message + ) + openedCourierMessageIds?.add(privateMessage.messageID, DROP_DEDUP_MS) + if (opened.consumedPrekey) broadcastPrekeyBundle(force = true) + return true + } + + private fun publishOrQueueDrop(envelope: CourierEnvelope, dedupKey: String?) { + if (!validEnvelopeLifetime(envelope)) return + if (relayManager?.isConnected?.value != true) { + enqueueDrop(PendingDrop(envelope, dedupKey)) + return + } + val encoded = envelope.encode() ?: return + if (encoded.size > MAX_DROP_BYTES) return + val event = NostrProtocol.createCourierDropEvent( + envelope = encoded, + recipientTagHex = envelope.recipientTag.toHex(), + expiresAtMs = envelope.expiry, + senderIdentity = NostrIdentity.generate() + ) + relayManager?.sendEvent(event, NostrRelayManager.defaultRelays()) + dedupKey?.let { publishedDropKeys?.add(it, DROP_DEDUP_MS) } + } + + private fun enqueueDrop(drop: PendingDrop) { + if (drop.dedupKey != null && pendingDrops.any { it.dedupKey == drop.dedupKey }) return + pendingDrops += drop + while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0) + } + + private fun flushPendingDrops() { + if (!_isEnabled.value || relayManager?.isConnected?.value != true) return + val queued = pendingDrops.toList() + pendingDrops.clear() + queued.forEach { publishOrQueueDrop(it.envelope, it.dedupKey) } + } + + private fun isMyCourierTag(tag: ByteArray): Boolean { + val ownKey = SecureIdentityStateManager(requireContext()).loadStaticKey()?.second ?: return false + return CourierEnvelope.candidateTags(ownKey).any { it.contentEquals(tag) } + } + + private fun validEnvelopeLifetime(envelope: CourierEnvelope): Boolean { + val now = System.currentTimeMillis() + return !envelope.isExpired(now) && + envelope.expiry > 0 && + envelope.expiry - now <= CourierEnvelope.MAX_LIFETIME_MS + } + + private fun closeSubscriptions() { + relayManager?.unsubscribe(BRIDGE_SUBSCRIPTION) + relayManager?.unsubscribe(COURIER_SUBSCRIPTION) + subscribedCells = emptySet() + subscribedCourierTags = emptySet() + } + + private fun isOwnEvent(event: NostrEvent, cell: String): Boolean = + runCatching { + NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext()) + .publicKeyHex.equals(event.pubkey, ignoreCase = true) + }.getOrDefault(false) + + private fun isFresh(event: NostrEvent): Boolean = + abs(System.currentTimeMillis() - 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 senderDropKey(messageId: String, recipientNoiseKey: ByteArray): String { + val material = recipientNoiseKey.toHex() + "|" + messageId + return MessageDigest.getInstance("SHA-256") + .digest(material.toByteArray(Charsets.UTF_8)) + .toHex() + } + + private fun verifyEd25519(signature: ByteArray, data: ByteArray, key: ByteArray): Boolean = + runCatching { + Ed25519Signer().apply { + init(false, Ed25519PublicKeyParameters(key, 0)) + update(data, 0, data.size) + }.verifySignature(signature) + }.getOrDefault(false) + + private fun currentMesh(): MeshService? = + MeshServiceHolder.unifiedMeshService + ?: appContext?.let { context -> + runCatching { MeshServiceHolder.getUnifiedOrCreate(context) }.getOrNull() + } + + private fun 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) } + + private 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() + } + + private class PersistentExpiringIdSet( + private val preferences: android.content.SharedPreferences, + private val key: String, + private val capacity: Int + ) { + private val gson = Gson() + private val values: LinkedHashMap = 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/PrekeyManager.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt new file mode 100644 index 00000000..107358d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt @@ -0,0 +1,352 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.noise.CourierNoiseCrypto +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +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 private constructor(context: Context) { + data class Sealed( + val ciphertext: ByteArray, + val prekeyId: Long? + ) + + data class Opened( + val payload: ByteArray, + val senderStaticKey: ByteArray, + val consumedPrekey: Boolean + ) + + private data class LocalRecord( + val id: Long, + val privateKey: String, + val createdAt: Long, + var consumedAt: Long? = null + ) + + private data class PersistedLocal( + var records: MutableList = mutableListOf(), + var nextId: Long = 0, + var generatedAt: Long = 0 + ) + + private data class StoredBundle( + val noiseKey: String, + var generatedAt: Long, + var prekeyIds: List, + var prekeyPublicKeys: List, + var usedIds: MutableSet, + var assignments: MutableMap, + var updatedAt: Long + ) + + private val appContext = context.applicationContext + private val identityState = SecureIdentityStateManager(appContext) + private val peerPrefs = appContext.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE) + private val gson = Gson() + private val lock = Any() + private var local: PersistedLocal? = null + private var peerBundles: MutableMap? = null + + fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) { + val staticKey = identityState.loadStaticKey()?.second ?: return@synchronized null + val signingPrivateKey = identityState.loadSigningKey()?.first ?: return@synchronized null + val 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] = StoredBundle( + 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 = identityState.loadStaticKey()?.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 = identityState.loadStaticKey()?.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 = PersistedLocal() + peerBundles = mutableMapOf() + identityState.clearSecureValues(LOCAL_STORE_KEY) + peerPrefs.edit { 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: PersistedLocal, nowMs: Long): Boolean { + val beforeRecords = state.records.size + val beforeUnconsumed = state.records.count { it.consumedAt == null } + pruneLocked(state, nowMs) + val unconsumed = state.records.count { it.consumedAt == null } + var changed = unconsumed != beforeUnconsumed + if (unconsumed < REPLENISH_THRESHOLD) { + val random = SecureRandom() + repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) { + val privateKey = ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes) + state.records += LocalRecord( + 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: PersistedLocal, nowMs: Long) { + state.records.removeAll { record -> + record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS } + ?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS) + } + } + + private fun advanceGeneratedAtLocked(state: PersistedLocal, nowMs: Long) { + state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1) + } + + private fun loadLocalLocked(): PersistedLocal { + local?.let { return it } + val loaded = runCatching { + identityState.getSecureValue(LOCAL_STORE_KEY) + ?.let { gson.fromJson(it, PersistedLocal::class.java) } + }.getOrNull() ?: PersistedLocal() + local = loaded + return loaded + } + + private fun persistLocalLocked(state: PersistedLocal) { + runCatching { identityState.storeSecureValue(LOCAL_STORE_KEY, gson.toJson(state)) } + .onFailure { Log.e(TAG, "Failed to persist local prekeys", it) } + } + + private fun loadPeerBundlesLocked(): MutableMap { + peerBundles?.let { return it } + val type = object : TypeToken>() {}.type + val values: List = runCatching { + peerPrefs.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 } + .also { peerBundles = it } + } + + private fun persistPeerBundlesLocked(bundles: MutableMap) { + peerPrefs.edit { putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())) } + } + + private fun isFresh(bundle: StoredBundle, 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 TAG = "PrekeyManager" + private const val LOCAL_STORE_KEY = "courier_prekeys_v1" + private const val PEER_PREFS = "bitchat_prekey_bundles" + private const val PEER_BUNDLES_KEY = "bundles_v1" + private const val REPLENISH_THRESHOLD = 3 + private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000 + private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000 + 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 ?: PrekeyManager(context).also { instance = it } + } + } +} 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 f137ac63..f8d28a9c 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -35,6 +35,7 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.services.bridge.MeshBridgeService /** * Feature row for displaying app capabilities @@ -226,6 +227,7 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val bridgeEnabled by MeshBridgeService.isEnabled.collectAsState() if (isPresented) { BitchatBottomSheet( @@ -404,6 +406,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 = MeshBridgeService::setEnabled + ) HorizontalDivider( modifier = Modifier.padding(start = 56.dp), 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 7273813b..173ab726 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -153,6 +153,7 @@ fun NicknameEditor( @Composable fun PeerCounter( connectedPeers: List, + bridgedPeopleCount: Int, joinedChannels: Set, hasUnreadChannels: Map, isConnected: Boolean, @@ -173,10 +174,10 @@ fun PeerCounter( } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { - // Mesh channel: show Bluetooth-connected peers (excluding self) - val count = connectedPeers.size + // Mesh channel: show directly connected and bridge-visible people. + val count = connectedPeers.size + bridgedPeopleCount val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh - Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray) + Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) meshBlue else Color.Gray) } } @@ -341,6 +342,8 @@ private fun MainHeader( val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val bridgeEnabled by com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collectAsStateWithLifecycle() + val bridgedParticipants by com.bitchat.android.services.bridge.MeshBridgeService.bridgedParticipants.collectAsStateWithLifecycle() // Bookmarks store for current geohash toggle (iOS parity) val context = androidx.compose.ui.platform.LocalContext.current @@ -443,8 +446,19 @@ private fun MainHeader( style = PoWIndicatorStyle.COMPACT ) Spacer(modifier = Modifier.width(2.dp)) + + if (bridgeEnabled) { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_mesh_bridge_active), + tint = Color(0xFF00A7C4), + modifier = Modifier.size(15.dp) + ) + } + PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, + bridgedPeopleCount = bridgedParticipants.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 78d5b571..6aae7f3a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -60,6 +60,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle() + val bridgeEnabled by com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collectAsStateWithLifecycle() + val nearbyOnly by com.bitchat.android.services.bridge.MeshBridgeService.nearbyOnly.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -237,7 +239,12 @@ fun ChatScreen(viewModel: ChatViewModel) { currentChannel = currentChannel, nickname = nickname, colorScheme = colorScheme, - showMediaButtons = showMediaButtons + showMediaButtons = showMediaButtons, + showBridgeControls = bridgeEnabled && + currentChannel == null && + selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location, + nearbyOnly = nearbyOnly, + onNearbyOnlyChange = com.bitchat.android.services.bridge.MeshBridgeService::setNearbyOnly ) } @@ -392,7 +399,10 @@ fun ChatInputSection( currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + showBridgeControls: Boolean = false, + nearbyOnly: Boolean = false, + onNearbyOnlyChange: (Boolean) -> Unit = {} ) { Surface( modifier = Modifier.fillMaxWidth(), @@ -429,6 +439,9 @@ fun ChatInputSection( currentChannel = currentChannel, nickname = nickname, showMediaButtons = showMediaButtons, + showBridgeControls = showBridgeControls, + nearbyOnly = nearbyOnly, + onNearbyOnlyChange = onNearbyOnlyChange, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 6dd02584..eae3316a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -121,6 +121,16 @@ fun formatMessageAsAnnotatedString( // iOS-style timestamp at the END (smaller, grey) // Timestamp (and optional PoW badge) + if (message.isBridged) { + builder.pushStyle( + SpanStyle( + color = Color(0xFF00A7C4), + fontSize = (BASE_FONT_SIZE - 2).sp + ) + ) + builder.append(" 🌐") + builder.pop() + } builder.pushStyle(SpanStyle( color = Color.Gray.copy(alpha = 0.7f), fontSize = (BASE_FONT_SIZE - 4).sp 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 4eb32739..16faaf9b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -973,6 +973,10 @@ class ChatViewModel( // Clear all mesh service data clearAllMeshServiceData() + + try { + com.bitchat.android.services.bridge.MeshBridgeService.wipe() + } catch (_: Exception) { } // Clear all cryptographic data clearAllCryptographicData() 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 91555b38..f645e1ba 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -171,7 +171,10 @@ fun MessageInput( currentChannel: String?, nickname: String, showMediaButtons: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showBridgeControls: Boolean = false, + nearbyOnly: Boolean = false, + onNearbyOnlyChange: (Boolean) -> Unit = {} ) { val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } @@ -187,6 +190,25 @@ fun MessageInput( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + 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) + ) + } + } + // Text input with placeholder OR visualizer when recording Box( modifier = Modifier.weight(1f) 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 ec137437..6f7c0182 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -40,6 +40,7 @@ import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.services.bridge.MeshBridgeService import com.bitchat.android.util.hexEncodedString @@ -68,6 +69,8 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val bridgeEnabled by MeshBridgeService.isEnabled.collectAsStateWithLifecycle() + val bridgedParticipants by MeshBridgeService.bridgedParticipants.collectAsStateWithLifecycle() val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } @@ -179,6 +182,13 @@ fun MeshPeerListSheet( onDismiss() } ) + + if (bridgeEnabled && bridgedParticipants.isNotEmpty()) { + BridgedPeopleSection( + participants = bridgedParticipants, + colorScheme = colorScheme + ) + } } } } @@ -213,6 +223,53 @@ fun MeshPeerListSheet( } } +@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 eab371e7..404d34a4 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 @@ -1493,6 +1493,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 1ece95a7..caa1089c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,6 +379,13 @@ pan and zoom to select a geohash 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..7a1c133a --- /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_0708L, + ciphertext = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), + copies = 3, + prekeyId = 0x89AB_CDEFL + ) + + assertEquals( + "010010000102030405060708090a0b0c0d0e0f" + + "0200080102030405060708" + + "030002aabb04000103" + + "05000489abcdef", + envelope.encode()!!.toHex() + ) + assertEquals(envelope, CourierEnvelope.decode(envelope.encode()!!)) + assertEquals( + "f7b87836e588a2b31b306605b3313744", + CourierEnvelope.recipientTag(ByteArray(32) { it.toByte() }, 1).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 78d72bee..c13b253d 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -38,6 +38,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()!! @@ -59,17 +71,15 @@ class IdentityAnnouncementTest { } @Test - fun `local announcement send advertises private media`() { + fun `local announcement send advertises prekeys and private media`() { val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! assertArrayEquals( - byteArrayOf(0x05, 0x02, 0x00, 0x01), + byteArrayOf(0x05, 0x02, 0x01, 0x01), encoded.takeLast(4).toByteArray() ) - assertTrue( - IdentityAnnouncement.decode(encoded)!! - .capabilities!! - .contains(PeerCapabilities.PRIVATE_MEDIA) - ) + val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!! + assertTrue(capabilities.contains(PeerCapabilities.PREKEYS)) + assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 577823ac..75e2d50c 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -56,6 +56,55 @@ class AppStateStoreTest { assertEquals(listOf(first, second), AppStateStore.publicMessages.value) } + @Test + fun `untrusted bridge radio hint cannot reserve another signed event id`() { + val first = BitchatMessage( + id = "signed-event-a", + sender = "alice#1111", + content = "same public coordinates", + timestamp = Date(1_700_000_000_000L), + senderPeerID = "bridge:1111", + isBridged = true, + bridgeRadioMessageIdHint = "radio-hint" + ) + val second = first.copy( + id = "signed-event-b", + sender = "mallory#2222", + senderPeerID = "bridge:2222" + ) + + AppStateStore.addPublicMessage(first) + AppStateStore.addPublicMessage(second) + + assertEquals(listOf(first, second), AppStateStore.publicMessages.value) + } + + @Test + fun `authenticated radio row replaces bridge aliases with the same hint`() { + val bridged = BitchatMessage( + id = "signed-event", + sender = "alice#1111", + content = "hello", + timestamp = Date(1_700_000_000_000L), + senderPeerID = "bridge:1111", + isBridged = true, + bridgeRadioMessageIdHint = "radio-message-id" + ) + val radio = BitchatMessage( + id = "radio-message-id", + sender = "alice", + content = "hello", + timestamp = bridged.timestamp, + senderPeerID = "0011223344556677" + ) + + AppStateStore.addPublicMessage(bridged) + AppStateStore.addPublicMessage(radio) + + assertEquals(listOf(radio), AppStateStore.publicMessages.value) + assertEquals(true, AppStateStore.hasRadioPublicMessage(radio.id)) + } + @Test fun `peer list merges transport updates instead of overwriting`() { AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer"))