feat: add mesh bridge and courier prekeys

This commit is contained in:
callebtc 2026-07-27 00:36:21 +02:00
parent 61588db474
commit b0f3bd34fc
37 changed files with 2760 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.util.toHexString
import 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}")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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()
}

View File

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

View File

@ -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<Prekey>,
val generatedAt: Long,
val signature: ByteArray
) {
data class Prekey(val id: Long, val publicKey: ByteArray) {
init {
require(id in 0..0xFFFF_FFFFL)
require(publicKey.size == KEY_LENGTH)
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey))
override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode()
}
fun signableBytes(): ByteArray {
val output = ByteArrayOutputStream(
1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES
)
output.write(SIGNING_CONTEXT.size)
output.write(SIGNING_CONTEXT)
output.write(fixedKey(noiseStaticPublicKey))
output.write(prekeys.size.coerceAtMost(0xFF))
prekeys.take(0xFF).forEach { prekey ->
output.write(uint32Bytes(prekey.id))
output.write(fixedKey(prekey.publicKey))
}
output.write(uint64Bytes(generatedAt))
return output.toByteArray()
}
fun encode(): ByteArray? {
if (noiseStaticPublicKey.size != KEY_LENGTH ||
signature.size != SIGNATURE_LENGTH ||
prekeys.isEmpty() ||
prekeys.size > MAX_PREKEYS ||
prekeys.map { it.id }.distinct().size != prekeys.size
) {
return null
}
val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH)
prekeys.forEach { prekey ->
if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null
entries.write(uint32Bytes(prekey.id))
entries.write(prekey.publicKey)
}
return 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<Prekey>? = null
var generatedAt: Long? = null
var signature: ByteArray? = null
while (offset < data.size) {
if (offset + 3 > data.size) return null
val type = data[offset].toInt() and 0xFF
val length =
((data[offset + 1].toInt() and 0xFF) shl 8) or
(data[offset + 2].toInt() and 0xFF)
offset += 3
if (offset + length > data.size) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
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<Prekey>()
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<List<String>>(listOf("r", cell))
nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) }
val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() }
if (sender != null && meshTimestampMs != null) {
tags += listOf(
"m",
MeshMessageIdentity.stableId(sender, meshTimestampMs, content),
sender,
meshTimestampMs.toString()
)
}
return senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = tags,
content = content
)
)
}
/** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */
fun createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
): NostrEvent = senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.GEOHASH_PRESENCE,
tags = listOf(listOf("r", cell)),
content = ""
)
)
/**
* Opaque relay drop. Callers use a throwaway identity so deposits cannot
* be linked by their Nostr publisher key.
*/
fun createCourierDropEvent(
envelope: ByteArray,
recipientTagHex: String,
expiresAtMs: Long,
senderIdentity: NostrIdentity
): NostrEvent = senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.COURIER_DROP,
tags = listOf(
listOf("x", recipientTagHex),
listOf("expiration", (expiresAtMs / 1000).toString())
),
content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP)
)
)
// MARK: - Private Methods

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<LocalRecord> = mutableListOf(),
var nextId: Long = 0,
var generatedAt: Long = 0
)
private data class StoredBundle(
val noiseKey: String,
var generatedAt: Long,
var prekeyIds: List<Long>,
var prekeyPublicKeys: List<String>,
var usedIds: MutableSet<Long>,
var assignments: MutableMap<String, Long>,
var updatedAt: Long
)
private val appContext = context.applicationContext
private val identityState = SecureIdentityStateManager(appContext)
private val peerPrefs = appContext.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE)
private val gson = Gson()
private val lock = Any()
private var local: PersistedLocal? = null
private var peerBundles: MutableMap<String, StoredBundle>? = null
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<String, StoredBundle> {
peerBundles?.let { return it }
val type = object : TypeToken<List<StoredBundle>>() {}.type
val values: List<StoredBundle> = runCatching {
peerPrefs.getString(PEER_BUNDLES_KEY, null)
?.let { json -> gson.fromJson<List<StoredBundle>>(json, type) }
}.getOrNull() ?: emptyList()
return values
.filter { it.prekeyIds.size == it.prekeyPublicKeys.size }
.associateByTo(mutableMapOf()) { it.noiseKey }
.also { peerBundles = it }
}
private fun persistPeerBundlesLocked(bundles: MutableMap<String, StoredBundle>) {
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 }
}
}
}

View File

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

View File

@ -153,6 +153,7 @@ fun NicknameEditor(
@Composable
fun PeerCounter(
connectedPeers: List<String>,
bridgedPeopleCount: Int,
joinedChannels: Set<String>,
hasUnreadChannels: Map<String, Int>,
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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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<MeshBridgeService.BridgedParticipant>,
colorScheme: ColorScheme
) {
Column(modifier = Modifier.padding(top = 16.dp)) {
Text(
text = stringResource(R.string.across_bridge).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
participants.forEach { participant ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = null,
tint = Color(0xFF00A7C4),
modifier = Modifier.size(18.dp)
)
Column {
Text(
text = participant.displayName,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = colorScheme.onSurface
)
Text(
text = stringResource(R.string.via_mesh_bridge),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.55f)
)
}
}
}
}
}
@Composable
private fun ChannelRow(
channel: String,

View File

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

View File

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

View File

@ -0,0 +1,163 @@
package com.bitchat.android.model
import com.bitchat.android.noise.CourierNoiseCrypto
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.nostr.NostrIdentity
import com.bitchat.android.nostr.NostrKind
import com.bitchat.android.nostr.NostrProtocol
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BridgeProtocolInteropTest {
@Test
fun `carrier TLVs match the iOS wire fixture`() {
val carrier = NostrCarrierPacket(
direction = NostrCarrierPacket.Direction.TO_BRIDGE,
geohash = "u4pruy",
eventJson = "{}".toByteArray()
)
assertEquals(
"010001030200067534707275790300027b7d",
carrier.encode().toHex()
)
assertEquals(carrier, NostrCarrierPacket.decode(carrier.encode()))
assertNull(NostrCarrierPacket.decode(carrier.encode().dropLast(1).toByteArray()))
}
@Test
fun `carrier decoder skips unknown TLVs`() {
val encoded = NostrCarrierPacket(
NostrCarrierPacket.Direction.FROM_BRIDGE,
"u4pruy",
"{}".toByteArray()
).encode()
val withUnknown = encoded + byteArrayOf(0x7F, 0x00, 0x02, 0x12, 0x34)
assertEquals(
NostrCarrierPacket.Direction.FROM_BRIDGE,
NostrCarrierPacket.decode(withUnknown)?.direction
)
}
@Test
fun `courier envelope and daily tag match iOS fixtures`() {
val envelope = CourierEnvelope(
recipientTag = ByteArray(16) { it.toByte() },
expiry = 0x0102_0304_0506_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) }
}

View File

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

View File

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