mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
feat: add mesh bridge and courier prekeys
This commit is contained in:
parent
61588db474
commit
b0f3bd34fc
@ -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) { }
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
163
app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt
Normal file
163
app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
|
||||
188
app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt
Normal file
188
app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user