mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Integrate opt-in mesh bridging and one-time courier prekeys
This commit is contained in:
parent
6cc437d172
commit
2243b4f416
@ -73,6 +73,15 @@ class BitchatApplication : Application() {
|
||||
// Initialize mesh service preferences
|
||||
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||
|
||||
// Bridge policy is process-scoped so rendezvous and courier delivery
|
||||
// continue while the activity is backgrounded.
|
||||
try {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.initialize(this)
|
||||
com.bitchat.android.mesh.BridgeMeshPort.install(
|
||||
com.bitchat.android.services.bridge.MeshBridgeService
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Proactively start the foreground service to keep mesh alive
|
||||
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
/**
|
||||
* Refresh available channels from current location
|
||||
*/
|
||||
fun refreshChannels() {
|
||||
fun refreshChannels(
|
||||
forceFresh: Boolean = false,
|
||||
updatePlaceNames: Boolean = true
|
||||
) {
|
||||
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
|
||||
requestOneShotLocation()
|
||||
requestOneShotLocation(forceFresh, updatePlaceNames)
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
|
||||
// MARK: - Location Operations
|
||||
|
||||
private fun requestOneShotLocation() {
|
||||
private fun requestOneShotLocation(
|
||||
forceFresh: Boolean = false,
|
||||
updatePlaceNames: Boolean = true
|
||||
) {
|
||||
if (!isLocationServicesEnabled() ||
|
||||
syncPermissionState() != PermissionState.AUTHORIZED
|
||||
) {
|
||||
@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
if (forceFresh) {
|
||||
requestFreshLocation(token, updatePlaceNames)
|
||||
return
|
||||
}
|
||||
|
||||
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
if (!canUseLiveLocation(token)) return@getLastKnownLocation
|
||||
|
||||
if (cached != null) {
|
||||
onLocationUpdated(cached, token)
|
||||
onLocationUpdated(cached, token, updatePlaceNames)
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (!canUseLiveLocation(token)) return@requestFreshLocation
|
||||
|
||||
if (fresh != null) {
|
||||
onLocationUpdated(fresh, token)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
requestFreshLocation(token, updatePlaceNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) _isLoadingLocation.value = false
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(location: Location, token: Long) {
|
||||
private fun requestFreshLocation(
|
||||
token: Long,
|
||||
updatePlaceNames: Boolean
|
||||
) {
|
||||
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (!canUseLiveLocation(token)) return@requestFreshLocation
|
||||
if (fresh != null) {
|
||||
onLocationUpdated(fresh, token, updatePlaceNames)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) _isLoadingLocation.value = false
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(
|
||||
location: Location,
|
||||
token: Long,
|
||||
updatePlaceNames: Boolean = true
|
||||
) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location, token)
|
||||
reverseGeocodeIfNeeded(location, token)
|
||||
if (updatePlaceNames) reverseGeocodeIfNeeded(location, token)
|
||||
}
|
||||
}
|
||||
|
||||
@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
_teleported.value = false
|
||||
}
|
||||
|
||||
/** Remove exact/transient location state without destroying the singleton. */
|
||||
fun panicReset() {
|
||||
clearLiveLocationState()
|
||||
clearPersistedChannel()
|
||||
}
|
||||
|
||||
// MARK: - Location Services State Persistence
|
||||
|
||||
/**
|
||||
|
||||
@ -696,6 +696,10 @@ class SecureIdentityStateManager {
|
||||
/**
|
||||
* Store a string value in secure preferences
|
||||
*/
|
||||
/** Persist keys and consumption state before making their use observable. */
|
||||
fun storeSecureValueSynchronously(key: String, value: String): Boolean =
|
||||
prefs.edit().putString(key, value).commit()
|
||||
|
||||
fun storeSecureValue(key: String, value: String) {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.protocol.MessagePadding
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
@ -21,6 +20,7 @@ import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.*
|
||||
@ -74,7 +74,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
PeerCapabilities.localSupported(),
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
@ -203,7 +203,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
if (isBleTransportEnabled()) {
|
||||
TransportBridgeService.register("BLE", this)
|
||||
}
|
||||
bridgeCourierService.start()
|
||||
serviceScope.launch {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collect { enabled ->
|
||||
if (enabled) bridgeCourierService.start() else bridgeCourierService.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Inject dynamic direct connection check into PeerManager
|
||||
// Matches iOS logic: checks if we have an active hardware mapping for this peer
|
||||
@ -433,7 +437,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
|
||||
// Cryptographic operations
|
||||
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return securityManager.verifySignature(packet, peerID)
|
||||
@ -629,6 +633,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun isPeerDirectlyConnected(peerID: String): Boolean =
|
||||
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
|
||||
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
@ -936,6 +943,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
@ -954,6 +962,57 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
// Track our own broadcast message for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
if (channel == null) {
|
||||
val nickname = runCatching {
|
||||
com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID)
|
||||
}.getOrNull()
|
||||
BridgeMeshPort.bridgeOutgoing(
|
||||
content,
|
||||
myPeerID,
|
||||
packet.timestamp.toLong(),
|
||||
nickname,
|
||||
bridgePolicyAtSend
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
|
||||
sendRawProtocolPacket(MessageType.NOSTR_CARRIER, payload, recipientPeerID, sign = true)
|
||||
}
|
||||
|
||||
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true)
|
||||
}
|
||||
|
||||
fun sendPrekeyBundle(payload: ByteArray) {
|
||||
sendRawProtocolPacket(MessageType.PREKEY_BUNDLE, payload, null, sign = true)
|
||||
}
|
||||
|
||||
private fun sendRawProtocolPacket(
|
||||
type: MessageType,
|
||||
payload: ByteArray,
|
||||
recipientPeerID: String?,
|
||||
sign: Boolean
|
||||
) {
|
||||
if (payload.isEmpty()) return
|
||||
serviceScope.launch {
|
||||
val packet = BridgeProtocolPacketFactory.protocolPacket(
|
||||
type = type,
|
||||
payload = payload,
|
||||
senderPeerId = myPeerID,
|
||||
recipientPeerId = recipientPeerID,
|
||||
ttl = MAX_TTL
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign &&
|
||||
type != MessageType.COURIER_ENVELOPE &&
|
||||
outgoing.signature?.size != 64
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
gossipSyncManager.onPublicPacketSeen(outgoing)
|
||||
broadcastRoutedPacket(RoutedPacket(outgoing))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1418,7 +1477,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||
@ -1480,7 +1543,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||
@ -1622,7 +1689,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
privateMessage
|
||||
).encode()
|
||||
val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return emptyList() }
|
||||
val sealed = try { com.bitchat.android.services.bridge.PrekeyManager.getInstance(context).seal(typedPayload, messageID, recipientNoiseKey, true) } catch (_: Exception) { return emptyList() }
|
||||
val now = System.currentTimeMillis()
|
||||
val couriers = courierPeerIDs.distinct().take(4)
|
||||
if (couriers.isEmpty()) return emptyList()
|
||||
@ -1633,7 +1700,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
com.bitchat.android.model.CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed,
|
||||
ciphertext = sealed.ciphertext,
|
||||
prekeyID = sealed.prekeyId?.toUInt(),
|
||||
copies = 4u
|
||||
)
|
||||
val payload = envelope.encode() ?: return@filter false
|
||||
@ -1674,7 +1742,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
if (envelope.expiry.toLong() <= now) return false
|
||||
val localKey = encryptionService.getStaticPublicKey() ?: return false
|
||||
if (envelope.matchesRecipient(localKey, now)) {
|
||||
val (senderKey, typedPayload) = try { encryptionService.openCourierPayload(envelope.ciphertext) } catch (_: Exception) { return false }
|
||||
val opened = try {
|
||||
com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)
|
||||
.open(envelope.ciphertext, envelope.prekeyID?.toLong())
|
||||
} catch (_: Exception) { return false }
|
||||
val senderKey = opened.senderStaticKey
|
||||
val typedPayload = opened.payload
|
||||
if (opened.consumedPrekey) {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.refreshPrekeys()
|
||||
}
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false
|
||||
if (noisePayload.type !in setOf(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
|
||||
98
app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt
Normal file
98
app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt
Normal file
@ -0,0 +1,98 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
/**
|
||||
* Immutable privacy decision captured when a public message is accepted.
|
||||
*
|
||||
* A later opt-in must not authorize a message that was composed while
|
||||
* bridging was disabled or nearby-only. Publication therefore requires both
|
||||
* this send-time decision and the current policy to allow bridging.
|
||||
*/
|
||||
class BridgeOutboundPolicy internal constructor(
|
||||
internal val enabled: Boolean,
|
||||
internal val nearbyOnly: Boolean
|
||||
) {
|
||||
val allowsBridging: Boolean
|
||||
get() = enabled && !nearbyOnly
|
||||
|
||||
fun permitsPublication(current: BridgeOutboundPolicy): Boolean =
|
||||
allowsBridging && current.allowsBridging
|
||||
|
||||
companion object {
|
||||
val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false)
|
||||
|
||||
internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy =
|
||||
BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-facing bridge boundary.
|
||||
*
|
||||
* BLE/Wi-Fi packet code depends only on this protocol surface; application
|
||||
* bootstrap installs the process bridge controller. Tests can install a fake
|
||||
* without constructing relay or persistence infrastructure.
|
||||
*/
|
||||
interface BridgeMeshDelegate {
|
||||
fun advertisedCell(): String?
|
||||
fun outboundPolicy(): BridgeOutboundPolicy
|
||||
|
||||
fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?,
|
||||
policyAtSend: BridgeOutboundPolicy
|
||||
)
|
||||
|
||||
fun handleAuthenticatedRadioMessage(messageId: String)
|
||||
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
|
||||
fun handlePrekeyPacket(packet: BitchatPacket)
|
||||
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
|
||||
}
|
||||
|
||||
object BridgeMeshPort : BridgeMeshDelegate {
|
||||
@Volatile
|
||||
private var delegate: BridgeMeshDelegate? = null
|
||||
|
||||
fun install(delegate: BridgeMeshDelegate) {
|
||||
this.delegate = delegate
|
||||
}
|
||||
|
||||
override fun advertisedCell(): String? = delegate?.advertisedCell()
|
||||
|
||||
override fun outboundPolicy(): BridgeOutboundPolicy =
|
||||
delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied
|
||||
|
||||
override fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?,
|
||||
policyAtSend: BridgeOutboundPolicy
|
||||
) {
|
||||
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend)
|
||||
}
|
||||
|
||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
delegate?.handleAuthenticatedRadioMessage(messageId)
|
||||
}
|
||||
|
||||
override fun handleVerifiedAnnouncement(
|
||||
peerId: String,
|
||||
announcement: IdentityAnnouncement
|
||||
) {
|
||||
delegate?.handleVerifiedAnnouncement(peerId, announcement)
|
||||
}
|
||||
|
||||
override fun handlePrekeyPacket(packet: BitchatPacket) {
|
||||
delegate?.handlePrekeyPacket(packet)
|
||||
}
|
||||
|
||||
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
|
||||
delegate?.handleCarrier(payload, fromPeerId, directedToUs)
|
||||
}
|
||||
|
||||
}
|
||||
@ -19,6 +19,7 @@ import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -69,7 +70,7 @@ class MeshCore(
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
PeerCapabilities.localSupported(),
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
@ -508,6 +509,9 @@ class MeshCore(
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun isPeerDirectlyConnected(peerID: String): Boolean =
|
||||
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
|
||||
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
@ -592,6 +596,7 @@ class MeshCore(
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
@ -606,6 +611,70 @@ class MeshCore(
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
if (channel == null) {
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
BridgeMeshPort.bridgeOutgoing(
|
||||
content,
|
||||
myPeerID,
|
||||
packet.timestamp.toLong(),
|
||||
nickname,
|
||||
bridgePolicyAtSend
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) {
|
||||
sendRawProtocolPacket(
|
||||
type = MessageType.NOSTR_CARRIER,
|
||||
payload = payload,
|
||||
recipientPeerID = recipientPeerID,
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
sendRawProtocolPacket(
|
||||
type = MessageType.COURIER_ENVELOPE,
|
||||
payload = payload,
|
||||
recipientPeerID = recipientPeerID,
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
fun sendPrekeyBundle(payload: ByteArray) {
|
||||
sendRawProtocolPacket(
|
||||
type = MessageType.PREKEY_BUNDLE,
|
||||
payload = payload,
|
||||
recipientPeerID = null,
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendRawProtocolPacket(
|
||||
type: MessageType,
|
||||
payload: ByteArray,
|
||||
recipientPeerID: String?,
|
||||
sign: Boolean
|
||||
) {
|
||||
if (payload.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BridgeProtocolPacketFactory.protocolPacket(
|
||||
type = type,
|
||||
payload = payload,
|
||||
senderPeerId = myPeerID,
|
||||
recipientPeerId = recipientPeerID,
|
||||
ttl = maxTtl
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign &&
|
||||
type != MessageType.COURIER_ENVELOPE &&
|
||||
outgoing.signature?.size != 64
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
dispatchGlobal(RoutedPacket(outgoing))
|
||||
}
|
||||
}
|
||||
|
||||
@ -976,7 +1045,11 @@ class MeshCore(
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@ -997,7 +1070,11 @@ class MeshCore(
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
|
||||
@ -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 com.bitchat.android.features.voice.LiveVoiceManager
|
||||
import com.bitchat.android.features.voice.LiveVoiceScope
|
||||
@ -398,6 +399,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
capabilities = announcement.capabilities
|
||||
) ?: false
|
||||
|
||||
BridgeMeshPort.handleVerifiedAnnouncement(
|
||||
peerID,
|
||||
announcement
|
||||
)
|
||||
|
||||
// Update mesh graph from gossip neighbors (only if TLV present)
|
||||
try {
|
||||
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||
@ -542,13 +548,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
// Fallback: plain text
|
||||
val message = BitchatMessage(
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
id = MeshMessageIdentity.stableId(
|
||||
peerID,
|
||||
packet.timestamp.toLong(),
|
||||
String(packet.payload, Charsets.UTF_8)
|
||||
),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = String(packet.payload, Charsets.UTF_8),
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
BridgeMeshPort.handleAuthenticatedRadioMessage(message.id)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
|
||||
}
|
||||
|
||||
@ -158,6 +158,21 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
MessageType.PREKEY_BUNDLE -> {
|
||||
BridgeMeshPort.handlePrekeyPacket(packet)
|
||||
}
|
||||
MessageType.NOSTR_CARRIER -> {
|
||||
val directedToUs = packetRelayManager.isPacketAddressedToMe(packet)
|
||||
val isBroadcast = packet.recipientID == null ||
|
||||
packet.recipientID.contentEquals(delegate?.getBroadcastRecipient())
|
||||
if (directedToUs || isBroadcast) {
|
||||
BridgeMeshPort.handleCarrier(
|
||||
packet.payload,
|
||||
peerID,
|
||||
directedToUs
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Handle private packet types (address check required)
|
||||
if (packetRelayManager.isPacketAddressedToMe(packet)) {
|
||||
@ -332,6 +347,7 @@ interface PacketProcessorDelegate {
|
||||
// Network information
|
||||
fun getNetworkSize(): Int
|
||||
fun getBroadcastRecipient(): ByteArray
|
||||
fun isPeerDirectlyConnected(peerID: String): Boolean = false
|
||||
|
||||
// Message type handlers
|
||||
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
|
||||
|
||||
@ -271,7 +271,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
MessageType.COURIER_ENVELOPE,
|
||||
MessageType.VOICE_FRAME,
|
||||
MessageType.LEAVE,
|
||||
MessageType.REQUEST_SYNC
|
||||
MessageType.REQUEST_SYNC,
|
||||
MessageType.NOSTR_CARRIER
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -101,6 +101,29 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendNostrCarrier(payload, recipientPeerID)
|
||||
else -> wifiService()?.sendNostrCarrier(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
when {
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendCourierEnvelope(payload, recipientPeerID)
|
||||
else -> wifiService()?.sendCourierEnvelope(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPrekeyBundle(payload: ByteArray) {
|
||||
if (isBleEnabled()) {
|
||||
bluetooth.sendPrekeyBundle(payload)
|
||||
} else {
|
||||
wifiService()?.sendPrekeyBundle(payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPrivateMessage(
|
||||
content: String,
|
||||
recipientPeerID: String,
|
||||
|
||||
@ -81,7 +81,15 @@ data class BitchatMessage(
|
||||
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
|
||||
* mesh IDs and private-chat routing aliases.
|
||||
*/
|
||||
val senderNostrPubkey: String? = null
|
||||
val senderNostrPubkey: String? = null,
|
||||
/** Rendered from a signed bridge rendezvous event rather than local radio. */
|
||||
val isBridged: Boolean = false,
|
||||
/**
|
||||
* Untrusted radio-coordinate hint from the bridge event. It may merge a
|
||||
* duplicate when the authenticated radio copy arrives, but never owns the
|
||||
* bridge row's primary ID.
|
||||
*/
|
||||
val bridgeRadioMessageIdHint: String? = null
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
|
||||
@ -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,101 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
|
||||
/**
|
||||
* Wire payload for MessageType.NOSTR_CARRIER (0x28).
|
||||
*
|
||||
* The TLV layout and limits intentionally match iOS. Lengths are unsigned
|
||||
* 16-bit big-endian values and unknown TLVs are skipped.
|
||||
*/
|
||||
data class NostrCarrierPacket(
|
||||
val direction: Direction,
|
||||
val geohash: String,
|
||||
val eventJson: ByteArray
|
||||
) {
|
||||
enum class Direction(val value: Int) {
|
||||
TO_GATEWAY(0x01),
|
||||
FROM_GATEWAY(0x02),
|
||||
TO_BRIDGE(0x03),
|
||||
FROM_BRIDGE(0x04);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): Direction? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
require(geohash.toByteArray(Charsets.UTF_8).size in 1..MAX_GEOHASH_LENGTH)
|
||||
require(eventJson.size in 1..MAX_EVENT_JSON_BYTES)
|
||||
}
|
||||
|
||||
fun event(): NostrEvent? =
|
||||
NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8))
|
||||
|
||||
fun encode(): ByteArray {
|
||||
return checkNotNull(
|
||||
Tlv16Codec.encode(
|
||||
Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())),
|
||||
Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)),
|
||||
Tlv16Codec.Field(TLV_EVENT_JSON, eventJson)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_EVENT_JSON_BYTES = 16 * 1024
|
||||
const val MAX_GEOHASH_LENGTH = 12
|
||||
|
||||
private const val TLV_DIRECTION = 0x01
|
||||
private const val TLV_GEOHASH = 0x02
|
||||
private const val TLV_EVENT_JSON = 0x03
|
||||
|
||||
fun fromEvent(direction: Direction, geohash: String, event: NostrEvent): NostrCarrierPacket? =
|
||||
runCatching {
|
||||
NostrCarrierPacket(
|
||||
direction = direction,
|
||||
geohash = geohash,
|
||||
eventJson = event.toJsonString().toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
fun decode(data: ByteArray): NostrCarrierPacket? {
|
||||
var direction: Direction? = null
|
||||
var geohash: String? = null
|
||||
var eventJson: ByteArray? = null
|
||||
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_DIRECTION -> {
|
||||
if (field.value.size != 1) return null
|
||||
direction =
|
||||
Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null
|
||||
}
|
||||
TLV_GEOHASH -> {
|
||||
geohash = field.value.toString(Charsets.UTF_8)
|
||||
}
|
||||
TLV_EVENT_JSON -> eventJson = field.value
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return runCatching {
|
||||
NostrCarrierPacket(
|
||||
direction = direction ?: return null,
|
||||
geohash = geohash ?: return null,
|
||||
eventJson = eventJson ?: return null
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is NostrCarrierPacket &&
|
||||
direction == other.direction &&
|
||||
geohash == other.geohash &&
|
||||
eventJson.contentEquals(other.eventJson))
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * direction.hashCode() + geohash.hashCode()) + eventJson.contentHashCode()
|
||||
}
|
||||
@ -31,13 +31,11 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
private const val PRIVATE_MEDIA_BIT_INDEX = 8
|
||||
val NONE = PeerCapabilities(0)
|
||||
|
||||
val PREKEYS = PeerCapabilities(1L shl 0)
|
||||
val WIFI_BULK = PeerCapabilities(1L shl 1)
|
||||
val GATEWAY = PeerCapabilities(1L shl 2)
|
||||
val GROUPS = PeerCapabilities(1L shl 3)
|
||||
val BOARD = PeerCapabilities(1L shl 4)
|
||||
val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6)
|
||||
val BRIDGE = PeerCapabilities(1L shl 7)
|
||||
|
||||
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl PRIVATE_MEDIA_BIT_INDEX)
|
||||
@ -50,8 +48,28 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
/** Reserved by iOS; decode it but do not advertise or act on it. */
|
||||
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
|
||||
|
||||
/** Can bridge public mesh traffic through geohash rendezvous relays. */
|
||||
val BRIDGE = PeerCapabilities(1L shl 7)
|
||||
|
||||
/** Publishes signed one-time prekeys for forward-secret courier mail. */
|
||||
val PREKEYS = PeerCapabilities(1L shl 0)
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue)
|
||||
@Deprecated("Use localSupported() so runtime bridge state is included")
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue)
|
||||
|
||||
@Volatile
|
||||
private var bridgeEnabled: Boolean = false
|
||||
|
||||
fun setBridgeEnabled(enabled: Boolean) {
|
||||
bridgeEnabled = enabled
|
||||
}
|
||||
|
||||
fun localSupported(): PeerCapabilities = PeerCapabilities(
|
||||
LOCAL_SUPPORTED.rawValue or
|
||||
PREKEYS.rawValue or
|
||||
if (bridgeEnabled) BRIDGE.rawValue else 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* Decode the low 64 bits and ignore any future extension bytes, which
|
||||
|
||||
171
app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt
Normal file
171
app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt
Normal file
@ -0,0 +1,171 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Signed batch of one-time Curve25519 keys carried by MessageType.PREKEY_BUNDLE (0x24).
|
||||
*
|
||||
* The canonical signing bytes and TLV representation intentionally match the
|
||||
* iOS BitFoundation implementation byte-for-byte.
|
||||
*/
|
||||
data class PrekeyBundle(
|
||||
val noiseStaticPublicKey: ByteArray,
|
||||
val prekeys: List<Prekey>,
|
||||
val generatedAt: Long,
|
||||
val signature: ByteArray
|
||||
) {
|
||||
data class Prekey(val id: Long, val publicKey: ByteArray) {
|
||||
init {
|
||||
require(id in 0..0xFFFF_FFFFL)
|
||||
require(publicKey.size == KEY_LENGTH)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey))
|
||||
|
||||
override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode()
|
||||
}
|
||||
|
||||
fun signableBytes(): ByteArray {
|
||||
val output = ByteArrayOutputStream(
|
||||
1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES
|
||||
)
|
||||
output.write(SIGNING_CONTEXT.size)
|
||||
output.write(SIGNING_CONTEXT)
|
||||
output.write(fixedKey(noiseStaticPublicKey))
|
||||
output.write(prekeys.size.coerceAtMost(0xFF))
|
||||
prekeys.take(0xFF).forEach { prekey ->
|
||||
output.write(uint32Bytes(prekey.id))
|
||||
output.write(fixedKey(prekey.publicKey))
|
||||
}
|
||||
output.write(uint64Bytes(generatedAt))
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (noiseStaticPublicKey.size != KEY_LENGTH ||
|
||||
signature.size != SIGNATURE_LENGTH ||
|
||||
prekeys.isEmpty() ||
|
||||
prekeys.size > MAX_PREKEYS ||
|
||||
prekeys.map { it.id }.distinct().size != prekeys.size
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH)
|
||||
prekeys.forEach { prekey ->
|
||||
if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null
|
||||
entries.write(uint32Bytes(prekey.id))
|
||||
entries.write(prekey.publicKey)
|
||||
}
|
||||
|
||||
return Tlv16Codec.encode(
|
||||
Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey),
|
||||
Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()),
|
||||
Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)),
|
||||
Tlv16Codec.Field(TLV_SIGNATURE, signature)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_LENGTH = 32
|
||||
const val SIGNATURE_LENGTH = 64
|
||||
const val MAX_PREKEYS = 8
|
||||
private const val PREKEY_ENTRY_LENGTH = 4 + KEY_LENGTH
|
||||
|
||||
private val SIGNING_CONTEXT = "bitchat-prekey-bundle-v1".toByteArray(Charsets.UTF_8)
|
||||
private const val TLV_NOISE_STATIC_KEY = 0x01
|
||||
private const val TLV_PREKEYS = 0x02
|
||||
private const val TLV_GENERATED_AT = 0x03
|
||||
private const val TLV_SIGNATURE = 0x04
|
||||
|
||||
fun decode(data: ByteArray): PrekeyBundle? {
|
||||
var noiseStaticKey: ByteArray? = null
|
||||
var prekeys: List<Prekey>? = null
|
||||
var generatedAt: Long? = null
|
||||
var signature: ByteArray? = null
|
||||
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_NOISE_STATIC_KEY -> {
|
||||
if (field.value.size != KEY_LENGTH) return null
|
||||
noiseStaticKey = field.value
|
||||
}
|
||||
TLV_PREKEYS -> {
|
||||
if (field.value.isEmpty() ||
|
||||
field.value.size % PREKEY_ENTRY_LENGTH != 0 ||
|
||||
field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val parsed = mutableListOf<Prekey>()
|
||||
var entryOffset = 0
|
||||
while (entryOffset < field.value.size) {
|
||||
val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.int.toLong() and 0xFFFF_FFFFL
|
||||
entryOffset += Int.SIZE_BYTES
|
||||
val publicKey =
|
||||
field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH)
|
||||
entryOffset += KEY_LENGTH
|
||||
parsed += Prekey(id, publicKey)
|
||||
}
|
||||
if (parsed.map { it.id }.distinct().size != parsed.size) return null
|
||||
prekeys = parsed
|
||||
}
|
||||
TLV_GENERATED_AT -> {
|
||||
if (field.value.size != Long.SIZE_BYTES) return null
|
||||
generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long
|
||||
}
|
||||
TLV_SIGNATURE -> {
|
||||
if (field.value.size != SIGNATURE_LENGTH) return null
|
||||
signature = field.value
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return runCatching {
|
||||
PrekeyBundle(
|
||||
noiseStaticPublicKey = noiseStaticKey ?: return null,
|
||||
prekeys = prekeys?.takeIf { it.isNotEmpty() } ?: return null,
|
||||
generatedAt = generatedAt ?: return null,
|
||||
signature = signature ?: return null
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun uint32Bytes(value: Long): ByteArray =
|
||||
ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putInt(value.toInt())
|
||||
.array()
|
||||
|
||||
private fun uint64Bytes(value: Long): ByteArray =
|
||||
ByteBuffer.allocate(Long.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putLong(value)
|
||||
.array()
|
||||
|
||||
private fun fixedKey(key: ByteArray): ByteArray =
|
||||
key.copyOf(KEY_LENGTH)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is PrekeyBundle &&
|
||||
noiseStaticPublicKey.contentEquals(other.noiseStaticPublicKey) &&
|
||||
prekeys == other.prekeys &&
|
||||
generatedAt == other.generatedAt &&
|
||||
signature.contentEquals(other.signature))
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = noiseStaticPublicKey.contentHashCode()
|
||||
result = 31 * result + prekeys.hashCode()
|
||||
result = 31 * result + generatedAt.hashCode()
|
||||
result = 31 * result + signature.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
47
app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt
Normal file
47
app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt
Normal file
@ -0,0 +1,47 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Minimal unsigned 16-bit big-endian TLV codec used by bridge wire models.
|
||||
*
|
||||
* Semantic validation intentionally remains in each model. This helper only
|
||||
* owns framing so every decoder rejects truncated and trailing data the same
|
||||
* way.
|
||||
*/
|
||||
internal object Tlv16Codec {
|
||||
data class Field(val type: Int, val value: ByteArray)
|
||||
|
||||
fun encode(vararg fields: Field): ByteArray? {
|
||||
val output = ByteArrayOutputStream(
|
||||
fields.sumOf { HEADER_SIZE + it.value.size }
|
||||
)
|
||||
fields.forEach { field ->
|
||||
if (field.type !in 0..0xFF || field.value.size > 0xFFFF) return null
|
||||
output.write(field.type)
|
||||
output.write((field.value.size ushr 8) and 0xFF)
|
||||
output.write(field.value.size and 0xFF)
|
||||
output.write(field.value)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): List<Field>? {
|
||||
val fields = mutableListOf<Field>()
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
if (data.size - offset < HEADER_SIZE) return null
|
||||
val type = data[offset].toInt() and 0xFF
|
||||
val length =
|
||||
((data[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 2].toInt() and 0xFF)
|
||||
offset += HEADER_SIZE
|
||||
if (length > data.size - offset) return null
|
||||
fields += Field(type, data.copyOfRange(offset, offset + length))
|
||||
offset += length
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
private const val HEADER_SIZE = 3
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,9 @@ internal interface BridgeCourierCipher {
|
||||
fun staticPublicKey(): ByteArray?
|
||||
|
||||
fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray
|
||||
|
||||
fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> =
|
||||
seal(payload, recipientNoiseKey) to null
|
||||
}
|
||||
|
||||
private class NostrBridgeCourierRelay(
|
||||
@ -77,12 +80,20 @@ private class NostrBridgeCourierRelay(
|
||||
}
|
||||
|
||||
private class EncryptionBridgeCourierCipher(
|
||||
private val encryptionService: EncryptionService
|
||||
private val encryptionService: EncryptionService,
|
||||
private val prekeys: com.bitchat.android.services.bridge.PrekeyManager
|
||||
) : BridgeCourierCipher {
|
||||
override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
|
||||
encryptionService.sealCourierPayload(payload, recipientNoiseKey)
|
||||
|
||||
override fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> {
|
||||
val id = java.security.MessageDigest.getInstance("SHA-256").digest(payload)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val sealed = prekeys.seal(payload, id, recipientNoiseKey, recipientAdvertisesPrekeys = true)
|
||||
return sealed.ciphertext to sealed.prekeyId?.toUInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */
|
||||
@ -92,18 +103,20 @@ class BridgeCourierService internal constructor(
|
||||
private val relayManager: BridgeCourierRelay,
|
||||
private val relayUrls: List<String> = NostrRelayManager.defaultRelays(),
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate
|
||||
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate,
|
||||
private val enabled: () -> Boolean = { true }
|
||||
) {
|
||||
constructor(
|
||||
context: Context,
|
||||
encryptionService: EncryptionService,
|
||||
onEnvelope: (CourierEnvelope) -> Unit
|
||||
) : this(
|
||||
cipher = EncryptionBridgeCourierCipher(encryptionService),
|
||||
cipher = EncryptionBridgeCourierCipher(encryptionService, com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)),
|
||||
onEnvelope = onEnvelope,
|
||||
relayManager = NostrBridgeCourierRelay(
|
||||
NostrRelayManager.getInstance(context.applicationContext)
|
||||
)
|
||||
),
|
||||
enabled = { com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.value }
|
||||
)
|
||||
|
||||
companion object {
|
||||
@ -125,6 +138,7 @@ class BridgeCourierService internal constructor(
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (!enabled()) return
|
||||
val localKey = cipher.staticPublicKey() ?: ByteArray(0)
|
||||
if (localKey.size == 32) {
|
||||
val now = clock()
|
||||
@ -177,14 +191,16 @@ class BridgeCourierService internal constructor(
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean {
|
||||
if (!enabled()) return false
|
||||
start()
|
||||
if (!relayManager.hasConnectedRelay(relayUrls)) return false
|
||||
val sealed = try { cipher.seal(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
|
||||
val sealed = try { cipher.sealWithPrekey(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
|
||||
val now = clock()
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)),
|
||||
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed,
|
||||
ciphertext = sealed.first,
|
||||
prekeyID = sealed.second,
|
||||
copies = 1u
|
||||
)
|
||||
val encoded = envelope.encode() ?: return false
|
||||
@ -202,6 +218,7 @@ class BridgeCourierService internal constructor(
|
||||
content = Base64.encodeToString(encoded, Base64.NO_WRAP)
|
||||
)
|
||||
)
|
||||
if (!enabled()) return false
|
||||
return relayManager.sendEvent(event, relayUrls, onAccepted)
|
||||
}
|
||||
|
||||
@ -214,6 +231,7 @@ class BridgeCourierService internal constructor(
|
||||
}
|
||||
|
||||
private fun handleEvent(event: NostrEvent) {
|
||||
if (!enabled()) return
|
||||
if (event.kind != KIND || !event.isValidSignature()) return
|
||||
synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return }
|
||||
if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -176,6 +176,39 @@ object NostrIdentityBridge {
|
||||
Log.d(TAG, "Used fallback geohash identity derivation")
|
||||
return fallbackIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the iOS-compatible bridge rendezvous identity. The domain label
|
||||
* prevents linking it to geohash-chat identity and the iteration is
|
||||
* encoded big-endian, matching CryptoKit's UInt32.bigEndian bytes.
|
||||
*/
|
||||
fun deriveBridgeIdentity(cell: String, context: Context): NostrIdentity {
|
||||
val label = "bridge|$cell"
|
||||
geohashIdentityCache[label]?.let { return it }
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
val seed = getOrCreateDeviceSeed(stateManager)
|
||||
val message = label.toByteArray(Charsets.UTF_8)
|
||||
|
||||
for (iteration in 0 until 10) {
|
||||
val input = message + byteArrayOf(
|
||||
(iteration ushr 24).toByte(),
|
||||
(iteration ushr 16).toByte(),
|
||||
(iteration ushr 8).toByte(),
|
||||
iteration.toByte()
|
||||
)
|
||||
val candidate = hmacSha256(seed, input).toHexStringLocal()
|
||||
if (NostrCrypto.isValidPrivateKey(candidate)) {
|
||||
return NostrIdentity.fromPrivateKey(candidate).also {
|
||||
geohashIdentityCache[label] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fallback = MessageDigest.getInstance("SHA-256").digest(seed + message)
|
||||
return NostrIdentity.fromPrivateKey(fallback.toHexStringLocal()).also {
|
||||
geohashIdentityCache[label] = it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate candidate key for a specific iteration (matches iOS implementation)
|
||||
|
||||
@ -65,6 +65,12 @@ object NostrProtocol {
|
||||
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
|
||||
|
||||
return try {
|
||||
val recipientTags = listOf(listOf("p", recipientIdentity.publicKeyHex))
|
||||
if (giftWrap.content.toByteArray(Charsets.UTF_8).size > 64 * 1024 ||
|
||||
giftWrap.kind != NostrKind.GIFT_WRAP || giftWrap.tags != recipientTags ||
|
||||
!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt) ||
|
||||
!giftWrap.isValidSignature()
|
||||
) return null
|
||||
// 1. Unwrap the gift wrap
|
||||
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
|
||||
?: run {
|
||||
@ -74,7 +80,7 @@ object NostrProtocol {
|
||||
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
|
||||
|
||||
if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) {
|
||||
if (seal.kind != NostrKind.SEAL || seal.tags.isNotEmpty() || !seal.isValidSignature()) {
|
||||
Log.w(TAG, "❌ Invalid NIP-17 seal signature")
|
||||
return null
|
||||
}
|
||||
@ -86,7 +92,10 @@ object NostrProtocol {
|
||||
return null
|
||||
}
|
||||
|
||||
if (seal.pubkey != rumor.pubkey) {
|
||||
if (seal.pubkey != rumor.pubkey || rumor.kind != NostrKind.DIRECT_MESSAGE ||
|
||||
(rumor.tags.isNotEmpty() && rumor.tags != recipientTags) || rumor.sig != null ||
|
||||
!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumor.createdAt)
|
||||
) {
|
||||
Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey")
|
||||
return null
|
||||
}
|
||||
@ -235,6 +244,73 @@ object NostrProtocol {
|
||||
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/** iOS-compatible public mesh event on a bridge rendezvous cell. */
|
||||
fun createBridgeMeshEvent(
|
||||
content: String,
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = null,
|
||||
meshSenderId: String? = null,
|
||||
meshTimestampMs: Long? = null
|
||||
): NostrEvent {
|
||||
val tags = mutableListOf<List<String>>(listOf("r", cell))
|
||||
nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) }
|
||||
val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (sender != null && meshTimestampMs != null) {
|
||||
tags += listOf(
|
||||
"m",
|
||||
MeshMessageIdentity.stableId(sender, meshTimestampMs, content),
|
||||
sender,
|
||||
meshTimestampMs.toString()
|
||||
)
|
||||
}
|
||||
return senderIdentity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.EPHEMERAL_EVENT,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */
|
||||
fun createBridgePresenceEvent(
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = senderIdentity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.GEOHASH_PRESENCE,
|
||||
tags = listOf(listOf("r", cell)),
|
||||
content = ""
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Opaque relay drop. Callers use a throwaway identity so deposits cannot
|
||||
* be linked by their Nostr publisher key.
|
||||
*/
|
||||
fun createCourierDropEvent(
|
||||
envelope: ByteArray,
|
||||
recipientTagHex: String,
|
||||
expiresAtMs: Long,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = senderIdentity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.COURIER_DROP,
|
||||
tags = listOf(
|
||||
listOf("x", recipientTagHex),
|
||||
listOf("expiration", (expiresAtMs / 1000).toString())
|
||||
),
|
||||
content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP)
|
||||
)
|
||||
)
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
@ -22,7 +22,9 @@ enum class MessageType(val value: UByte) {
|
||||
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
|
||||
VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync
|
||||
BOARD_POST(0x23u),
|
||||
GROUP_MESSAGE(0x25u); // Opaque private-group ciphertext broadcast
|
||||
GROUP_MESSAGE(0x25u), // Opaque private-group ciphertext broadcast
|
||||
PREKEY_BUNDLE(0x24u), // Signed batch of one-time courier prekeys
|
||||
NOSTR_CARRIER(0x28u); // Signed bridge/gateway event carrier
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
|
||||
@ -188,6 +188,11 @@ object AppStateStore {
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (!msg.isBridged) {
|
||||
_publicMessages.value = _publicMessages.value.filterNot {
|
||||
it.isBridged && it.bridgeRadioMessageIdHint == msg.id
|
||||
}
|
||||
}
|
||||
val publicKey = publicMessageKey(msg)
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
@ -196,6 +201,10 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) {
|
||||
_publicMessages.value.any { !it.isBridged && it.id == messageId }
|
||||
}
|
||||
|
||||
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
|
||||
fun upsertPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Owns public-timeline replay deduplication and bridge/radio reconciliation.
|
||||
*
|
||||
* The store remains responsible for synchronization and cross-timeline IDs;
|
||||
* this class keeps bridge-specific alias policy independently testable.
|
||||
*/
|
||||
internal class PublicMessageReconciler {
|
||||
data class Result(
|
||||
val messages: List<BitchatMessage>,
|
||||
val accepted: Boolean
|
||||
)
|
||||
|
||||
private val seenKeys = mutableSetOf<String>()
|
||||
|
||||
fun reconcile(
|
||||
existing: List<BitchatMessage>,
|
||||
incoming: BitchatMessage,
|
||||
messageIdAlreadySeen: Boolean
|
||||
): Result {
|
||||
val withoutBridgeAliases = if (incoming.isBridged) {
|
||||
existing
|
||||
} else {
|
||||
existing.filterNot {
|
||||
it.isBridged && it.bridgeRadioMessageIdHint == incoming.id
|
||||
}
|
||||
}
|
||||
val key = publicMessageKey(incoming)
|
||||
if (messageIdAlreadySeen || key in seenKeys) {
|
||||
return Result(withoutBridgeAliases, accepted = false)
|
||||
}
|
||||
seenKeys += key
|
||||
return Result(withoutBridgeAliases + incoming, accepted = true)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
seenKeys.clear()
|
||||
}
|
||||
|
||||
private fun publicMessageKey(message: BitchatMessage): String {
|
||||
val sender = message.senderPeerID ?: message.sender
|
||||
return listOf(
|
||||
sender,
|
||||
message.timestamp.time.toString(),
|
||||
message.type.name,
|
||||
message.channel ?: "",
|
||||
message.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
internal class BoundedIdSet(private val capacity: Int) {
|
||||
private val values = LinkedHashSet<String>()
|
||||
|
||||
fun add(id: String): Boolean {
|
||||
if (!values.add(id)) return false
|
||||
while (values.size > capacity) values.remove(values.first())
|
||||
return true
|
||||
}
|
||||
|
||||
fun contains(id: String): Boolean = id in values
|
||||
|
||||
fun clear() = values.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one logical relay subscription while assigning a distinct wire ID to
|
||||
* every replacement. Relay CLOSE/REQ writes may execute out of order, but a
|
||||
* delayed close can only affect the retired generation.
|
||||
*
|
||||
* Callers keep this object confined to their coordinator dispatcher.
|
||||
*/
|
||||
internal class RelaySubscriptionSlot(private val idPrefix: String) {
|
||||
private var generation = 0L
|
||||
private var activeId: String? = null
|
||||
|
||||
fun replace(close: (String) -> Unit, open: (String) -> Unit): String {
|
||||
activeId?.let(close)
|
||||
val replacementId = "$idPrefix-${++generation}"
|
||||
activeId = replacementId
|
||||
try {
|
||||
open(replacementId)
|
||||
} catch (error: Throwable) {
|
||||
activeId = null
|
||||
throw error
|
||||
}
|
||||
return replacementId
|
||||
}
|
||||
|
||||
fun close(close: (String) -> Unit) {
|
||||
val id = activeId ?: return
|
||||
activeId = null
|
||||
close(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small insertion-ordered expiring set. Callers own synchronization; bridge
|
||||
* coordinators keep each instance confined to their serial dispatcher.
|
||||
*/
|
||||
internal class PersistentExpiringIdSet(
|
||||
private val preferences: SharedPreferences,
|
||||
private val key: String,
|
||||
private val capacity: Int
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val values: LinkedHashMap<String, Long> = load()
|
||||
|
||||
fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
prune(nowMs)
|
||||
return (values[id] ?: return false) > nowMs
|
||||
}
|
||||
|
||||
fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) {
|
||||
prune(nowMs)
|
||||
values.remove(id)
|
||||
values[id] = nowMs + lifetimeMs
|
||||
while (values.size > capacity) values.remove(values.keys.first())
|
||||
persist()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
values.clear()
|
||||
preferences.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun prune(nowMs: Long) {
|
||||
val changed = values.entries.removeAll { it.value <= nowMs }
|
||||
if (changed) persist()
|
||||
}
|
||||
|
||||
private fun load(): LinkedHashMap<String, Long> {
|
||||
val type = object : TypeToken<Map<String, Long>>() {}.type
|
||||
val decoded: Map<String, Long> = runCatching {
|
||||
preferences.getString(key, null)
|
||||
?.let { json -> gson.fromJson<Map<String, Long>>(json, type) }
|
||||
}.getOrNull() ?: emptyMap()
|
||||
return LinkedHashMap(decoded)
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
preferences.edit { putString(key, gson.toJson(values)) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
|
||||
data class BridgedParticipant(
|
||||
val pubkey: String,
|
||||
val nickname: String?,
|
||||
val lastSeenMs: Long
|
||||
) {
|
||||
val displayName: String
|
||||
get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}"
|
||||
}
|
||||
|
||||
data class BridgeUiState(
|
||||
val enabled: Boolean = false,
|
||||
val nearbyOnly: Boolean = false,
|
||||
val participants: List<BridgedParticipant> = emptyList()
|
||||
)
|
||||
|
||||
sealed interface CourierDepositResult {
|
||||
data object Published : CourierDepositResult
|
||||
data object ForwardedToGateway : CourierDepositResult
|
||||
data object QueuedLocally : CourierDepositResult
|
||||
data object AlreadyPublished : CourierDepositResult
|
||||
data class Rejected(val reason: Reason) : CourierDepositResult
|
||||
|
||||
enum class Reason {
|
||||
BRIDGE_DISABLED,
|
||||
CONTENT_TOO_LARGE,
|
||||
INVALID_MESSAGE,
|
||||
ENCRYPTION_FAILED
|
||||
}
|
||||
}
|
||||
|
||||
internal data class VerifiedBridgePeer(
|
||||
val peerId: String,
|
||||
val nickname: String,
|
||||
val noiseKey: ByteArray,
|
||||
val signingKey: ByteArray,
|
||||
val isVerifiedNickname: Boolean,
|
||||
val capabilities: PeerCapabilities?,
|
||||
val bridgeCell: String?,
|
||||
val lastSeenMs: Long
|
||||
)
|
||||
@ -0,0 +1,40 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
|
||||
/**
|
||||
* Verifies bridge protocol packets against the signing key bound by the
|
||||
* sender's authenticated announcement.
|
||||
*/
|
||||
internal object BridgePacketSignatureVerifier {
|
||||
fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean {
|
||||
val signature = packet.signature?.takeIf { it.size == 64 } ?: return false
|
||||
val signingData = packet.toBinaryDataForSigning() ?: return false
|
||||
if (publicKey.size != 32) return false
|
||||
return runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(false, Ed25519PublicKeyParameters(publicKey, 0))
|
||||
update(signingData, 0, signingData.size)
|
||||
}.verifySignature(signature)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
internal object CourierDepositAuthenticator {
|
||||
fun authenticate(
|
||||
packet: BitchatPacket,
|
||||
fromPeerId: String,
|
||||
directIngress: Boolean,
|
||||
peers: List<VerifiedBridgePeer>,
|
||||
isTrusted: (VerifiedBridgePeer) -> Boolean
|
||||
): VerifiedBridgePeer? {
|
||||
if (!directIngress || packet.senderID.toHex() != fromPeerId) return null
|
||||
val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null
|
||||
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null
|
||||
return peer.takeIf(isTrusted)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.mesh.MeshPacketUtils
|
||||
import com.bitchat.android.mesh.BridgeMeshPort
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
|
||||
/**
|
||||
* Shared construction policy for bridge protocol packets emitted by BLE and
|
||||
* Wi-Fi Aware mesh implementations.
|
||||
*/
|
||||
internal object BridgeProtocolPacketFactory {
|
||||
fun protocolPacket(
|
||||
type: MessageType,
|
||||
payload: ByteArray,
|
||||
senderPeerId: String,
|
||||
recipientPeerId: String?,
|
||||
ttl: UByte,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): BitchatPacket? {
|
||||
if (payload.isEmpty()) return null
|
||||
return BitchatPacket(
|
||||
version = if (payload.size > 0xFFFF) 2u else 1u,
|
||||
type = type.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(senderPeerId),
|
||||
recipientID = recipientPeerId?.let(MeshPacketUtils::hexStringToByteArray),
|
||||
timestamp = nowMs.toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = ttl
|
||||
)
|
||||
}
|
||||
|
||||
fun identityAnnouncement(
|
||||
nickname: String,
|
||||
noiseStaticKey: ByteArray,
|
||||
signingKey: ByteArray
|
||||
): IdentityAnnouncement =
|
||||
IdentityAnnouncement.forLocalPeer(
|
||||
nickname,
|
||||
noiseStaticKey,
|
||||
signingKey,
|
||||
BridgeMeshPort.advertisedCell()
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,772 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.BridgeMeshDelegate
|
||||
import com.bitchat.android.mesh.BridgeOutboundPolicy
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NostrCarrierPacket
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.nostr.MeshMessageIdentity
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
import com.bitchat.android.nostr.NostrFilter
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.nostr.NostrKind
|
||||
import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.ui.DataManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Opt-in bridge policy shared by foreground transport and Compose UI.
|
||||
*
|
||||
* Outbound public traffic crosses the bridge only when the author opted in
|
||||
* and did not mark the message nearby-only. Passive `fromBridge` reception is
|
||||
* accepted regardless of the switch because it exposes no local traffic.
|
||||
*/
|
||||
object MeshBridgeService : BridgeMeshDelegate {
|
||||
private data class PendingUplink(
|
||||
val depositor: String,
|
||||
val cell: String,
|
||||
val event: NostrEvent
|
||||
)
|
||||
|
||||
private data class PendingDownlink(
|
||||
val cell: String,
|
||||
val event: NostrEvent
|
||||
)
|
||||
|
||||
private const val TAG = "MeshBridgeService"
|
||||
private const val PREFS = "bitchat_bridge"
|
||||
private const val KEY_ENABLED = "bridge_enabled_v1"
|
||||
private const val CELL_PRECISION = 6
|
||||
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
|
||||
private const val MAX_CONTENT_BYTES = 16_000
|
||||
private const val MAX_TRACKED_IDS = 512
|
||||
private const val MAX_PARTICIPANTS = 128
|
||||
private const val PARTICIPANT_FRESH_MS = 10L * 60 * 1000
|
||||
private const val PRESENCE_INTERVAL_MS = 4L * 60 * 1000
|
||||
private const val MAX_QUEUED_UPLINKS = 20
|
||||
private const val MAX_UPLINKS_PER_DEPOSITOR = 5
|
||||
private const val UPLINKS_PER_MINUTE_PER_DEPOSITOR = 10
|
||||
private const val MAX_PENDING_DOWNLINKS = 30
|
||||
private const val DOWNLINKS_PER_MINUTE = 20
|
||||
private const val INBOUND_PER_MINUTE = 600
|
||||
private const val INBOUND_PER_SIGNER_PER_MINUTE = 120
|
||||
private const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720
|
||||
|
||||
private val dispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val _isEnabled = MutableStateFlow(false)
|
||||
val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow()
|
||||
private val _nearbyOnly = MutableStateFlow(false)
|
||||
val nearbyOnly: StateFlow<Boolean> = _nearbyOnly.asStateFlow()
|
||||
private val _activeCell = MutableStateFlow<String?>(null)
|
||||
val activeCell: StateFlow<String?> = _activeCell.asStateFlow()
|
||||
private val _bridgedParticipants = MutableStateFlow<List<BridgedParticipant>>(emptyList())
|
||||
val bridgedParticipants: StateFlow<List<BridgedParticipant>> = _bridgedParticipants.asStateFlow()
|
||||
private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied)
|
||||
private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous")
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
private var relayManager: NostrRelayManager? = null
|
||||
private var prekeyCoordinator: PrekeyCoordinator? = null
|
||||
private var prefs: android.content.SharedPreferences? = null
|
||||
@Volatile
|
||||
private var meshProvider: () -> MeshService? = { null }
|
||||
private var clock: () -> Long = System::currentTimeMillis
|
||||
private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
|
||||
private var localLocationCell: String? = null
|
||||
private var subscribedCells: Set<String> = emptySet()
|
||||
private val verifiedPeers = linkedMapOf<String, VerifiedBridgePeer>()
|
||||
private val verifiedPeerSnapshot = AtomicReference<List<VerifiedBridgePeer>>(emptyList())
|
||||
private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val rebroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val injectedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val queuedUplinks = mutableListOf<PendingUplink>()
|
||||
private val pendingDownlinks = mutableListOf<PendingDownlink>()
|
||||
private val participants = linkedMapOf<String, BridgedParticipant>()
|
||||
private val uplinkTimes = mutableMapOf<String, MutableList<Long>>()
|
||||
private val inboundTimes = mutableListOf<Long>()
|
||||
private val inboundTimesBySigner = mutableMapOf<String, MutableList<Long>>()
|
||||
private val downlinkTimes = mutableListOf<Long>()
|
||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||
private var downlinkJob: Job? = null
|
||||
private var presenceJob: Job? = null
|
||||
@Volatile
|
||||
private var suppressPrekeyBroadcasts = false
|
||||
@Volatile
|
||||
private var panicQuiesced = false
|
||||
|
||||
fun initialize(
|
||||
context: Context,
|
||||
meshProvider: () -> MeshService? = {
|
||||
com.bitchat.android.service.MeshServiceHolder.unifiedMeshService
|
||||
},
|
||||
clock: () -> Long = System::currentTimeMillis,
|
||||
jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
|
||||
) {
|
||||
if (appContext != null) return
|
||||
synchronized(this) {
|
||||
if (appContext != null) return
|
||||
val application = context.applicationContext
|
||||
appContext = application
|
||||
this.meshProvider = meshProvider
|
||||
this.clock = clock
|
||||
this.jitter = jitter
|
||||
relayManager = NostrRelayManager.getInstance(application)
|
||||
val prekeyManager = PrekeyManager.getInstance(application)
|
||||
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val storedEnabled = loadEnabledWithMigration(prefs!!)
|
||||
_isEnabled.value = storedEnabled && !panicQuiesced
|
||||
if (panicQuiesced && storedEnabled) {
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, false) }
|
||||
}
|
||||
outboundPolicy.set(
|
||||
BridgeOutboundPolicy.capture(
|
||||
enabled = _isEnabled.value,
|
||||
nearbyOnly = _nearbyOnly.value
|
||||
)
|
||||
)
|
||||
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
|
||||
prekeyCoordinator = PrekeyCoordinator(
|
||||
manager = prekeyManager,
|
||||
meshProvider = ::currentMesh,
|
||||
peersProvider = verifiedPeerSnapshot::get,
|
||||
clock = clock
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
val location = LocationChannelManager.getInstance(context)
|
||||
scope.launch {
|
||||
location.availableChannels.collect { channels ->
|
||||
if (!_isEnabled.value) {
|
||||
localLocationCell = null
|
||||
return@collect
|
||||
}
|
||||
localLocationCell = channels
|
||||
.firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD }
|
||||
?.geohash
|
||||
?.take(CELL_PRECISION)
|
||||
refreshRendezvous()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
relayManager?.isConnected?.collect { connected ->
|
||||
if (connected) {
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
flushQueuedUplinks()
|
||||
publishPresence()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
val defaultRelays = NostrRelayManager.defaultRelays().toSet()
|
||||
relayManager?.relays
|
||||
?.map { relays ->
|
||||
relays.asSequence()
|
||||
.filter { it.isConnected && it.url in defaultRelays }
|
||||
.map { it.url }
|
||||
.toSet()
|
||||
}
|
||||
?.distinctUntilChanged()
|
||||
?.collect { connectedDefaults ->
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
if (_isEnabled.value) {
|
||||
relayManager?.connect()
|
||||
location.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
}
|
||||
if (_isEnabled.value) startPresenceLoop()
|
||||
delay(2_000)
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
if (outboundPolicy.get().enabled == enabled) return
|
||||
if (enabled) {
|
||||
panicQuiesced = false
|
||||
suppressPrekeyBroadcasts = false
|
||||
}
|
||||
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
|
||||
_isEnabled.value = enabled
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||
PeerCapabilities.setBridgeEnabled(enabled)
|
||||
_nearbyOnly.value = false
|
||||
scope.launch {
|
||||
if (!enabled) {
|
||||
stopPresenceLoop()
|
||||
closeSubscriptions()
|
||||
queuedUplinks.clear()
|
||||
pendingDownlinks.clear()
|
||||
participants.clear()
|
||||
publishParticipants()
|
||||
localLocationCell = null
|
||||
_activeCell.value = null
|
||||
} else {
|
||||
startPresenceLoop()
|
||||
relayManager?.connect()
|
||||
LocationChannelManager.getInstance(requireContext())
|
||||
.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
currentMesh()?.sendBroadcastAnnounce()
|
||||
}
|
||||
}
|
||||
|
||||
fun setNearbyOnly(enabled: Boolean) {
|
||||
outboundPolicy.updateAndGet { current ->
|
||||
BridgeOutboundPolicy.capture(current.enabled, nearbyOnly = enabled)
|
||||
}
|
||||
_nearbyOnly.value = enabled
|
||||
}
|
||||
|
||||
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
|
||||
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
|
||||
|
||||
override fun outboundPolicy(): BridgeOutboundPolicy = outboundPolicy.get()
|
||||
|
||||
override fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?,
|
||||
policyAtSend: BridgeOutboundPolicy
|
||||
) {
|
||||
scope.launch {
|
||||
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
|
||||
val cell = _activeCell.value ?: currentCell() ?: return@launch
|
||||
if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch
|
||||
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
val event = NostrProtocol.createBridgeMeshEvent(
|
||||
content = content,
|
||||
cell = cell,
|
||||
senderIdentity = identity,
|
||||
nickname = nickname,
|
||||
meshSenderId = senderPeerId,
|
||||
meshTimestampMs = timestampMs
|
||||
)
|
||||
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
|
||||
publishedEventIds.add(event.id)
|
||||
injectedEventIds.add(event.id)
|
||||
if (relayManager?.isConnected?.value == true) {
|
||||
relayManager?.sendEventToGeohash(event, cell)
|
||||
} else {
|
||||
val peer = availableBridgePeer() ?: return@launch
|
||||
NostrCarrierPacket.fromEvent(
|
||||
NostrCarrierPacket.Direction.TO_BRIDGE,
|
||||
cell,
|
||||
event
|
||||
)?.encode()?.let { currentMesh()?.sendNostrCarrier(it, peer.peerId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only after a public radio packet's Ed25519 signature was accepted. */
|
||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
if (panicQuiesced || messageId.isBlank()) return
|
||||
scope.launch {
|
||||
radioMessageIds.add(messageId)
|
||||
pendingDownlinks.removeAll { pending ->
|
||||
classifyMessage(pending.event, pending.cell)?.bridgeRadioMessageIdHint == messageId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
|
||||
if (panicQuiesced) return
|
||||
scope.launch {
|
||||
val peer = VerifiedBridgePeer(
|
||||
peerId = peerId,
|
||||
nickname = announcement.nickname,
|
||||
noiseKey = announcement.noisePublicKey.copyOf(),
|
||||
signingKey = announcement.signingPublicKey.copyOf(),
|
||||
isVerifiedNickname =
|
||||
currentMesh()?.getPeerInfo(peerId)?.isVerifiedNickname == true,
|
||||
capabilities = announcement.capabilities,
|
||||
bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash),
|
||||
lastSeenMs = clock()
|
||||
)
|
||||
verifiedPeers[peerId] = peer
|
||||
while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first())
|
||||
publishVerifiedPeerSnapshot()
|
||||
prekeyCoordinator?.handlePeerVerified(peerId)
|
||||
if (_isEnabled.value) {
|
||||
refreshRendezvous()
|
||||
}
|
||||
broadcastPrekeys()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handlePrekeyPacket(packet: BitchatPacket) {
|
||||
if (panicQuiesced) return
|
||||
scope.launch { prekeyCoordinator?.handlePacket(packet) }
|
||||
}
|
||||
|
||||
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
|
||||
if (panicQuiesced) return
|
||||
scope.launch {
|
||||
val carrier = NostrCarrierPacket.decode(payload) ?: return@launch
|
||||
when (carrier.direction) {
|
||||
NostrCarrierPacket.Direction.TO_BRIDGE -> {
|
||||
if (directedToUs) handleUplink(carrier, fromPeerId)
|
||||
}
|
||||
NostrCarrierPacket.Direction.FROM_BRIDGE -> {
|
||||
if (!directedToUs) handleDownlink(carrier)
|
||||
}
|
||||
NostrCarrierPacket.Direction.TO_GATEWAY,
|
||||
NostrCarrierPacket.Direction.FROM_GATEWAY -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref", "UseKtx")
|
||||
suspend fun wipe() {
|
||||
// Revoke policy synchronously so no already-queued bridge work can be
|
||||
// authorized by the pre-panic setting.
|
||||
outboundPolicy.set(BridgeOutboundPolicy.Denied)
|
||||
panicQuiesced = true
|
||||
suppressPrekeyBroadcasts = true
|
||||
_isEnabled.value = false
|
||||
_nearbyOnly.value = false
|
||||
PeerCapabilities.setBridgeEnabled(false)
|
||||
kotlinx.coroutines.withContext(dispatcher) {
|
||||
prefs?.edit()?.putBoolean(KEY_ENABLED, false)?.commit()
|
||||
stopPresenceLoop()
|
||||
downlinkJob?.cancel()
|
||||
downlinkJob = null
|
||||
closeSubscriptions()
|
||||
queuedUplinks.clear()
|
||||
pendingDownlinks.clear()
|
||||
localLocationCell = null
|
||||
_activeCell.value = null
|
||||
verifiedPeers.clear()
|
||||
publishVerifiedPeerSnapshot()
|
||||
participants.clear()
|
||||
publishedEventIds.clear()
|
||||
receivedEventIds.clear()
|
||||
meshBroadcastEventIds.clear()
|
||||
rebroadcastEventIds.clear()
|
||||
injectedEventIds.clear()
|
||||
radioMessageIds.clear()
|
||||
uplinkTimes.clear()
|
||||
inboundTimes.clear()
|
||||
inboundTimesBySigner.clear()
|
||||
downlinkTimes.clear()
|
||||
signatureAttemptTimes.clear()
|
||||
prekeyCoordinator?.wipe()
|
||||
publishParticipants()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishVerifiedPeerSnapshot() {
|
||||
verifiedPeerSnapshot.set(
|
||||
verifiedPeers.values.map { peer ->
|
||||
peer.copy(
|
||||
noiseKey = peer.noiseKey.copyOf(),
|
||||
signingKey = peer.signingKey.copyOf()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun refreshRendezvous(forceSubscriptions: Boolean = false) {
|
||||
if (!_isEnabled.value) return
|
||||
val cell = currentCell()
|
||||
val changed = cell != _activeCell.value
|
||||
if (changed) {
|
||||
_activeCell.value = cell
|
||||
currentMesh()?.sendBroadcastAnnounce()
|
||||
}
|
||||
if (cell == null) return
|
||||
val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) }
|
||||
if (changed || forceSubscriptions || cells != subscribedCells) {
|
||||
val targets = linkedSetOf<String>()
|
||||
cells.forEach { subscribedCell ->
|
||||
relayManager?.ensureGeohashRelaysConnected(subscribedCell)
|
||||
targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty()
|
||||
}
|
||||
relayManager?.let { manager ->
|
||||
rendezvousSubscription.replace(
|
||||
close = manager::unsubscribe,
|
||||
open = { subscriptionId ->
|
||||
manager.subscribe(
|
||||
filter = NostrFilter.bridgeRendezvous(
|
||||
cells,
|
||||
since = clock() - MAX_EVENT_AGE_MS
|
||||
),
|
||||
id = subscriptionId,
|
||||
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
|
||||
targetRelayUrls = targets.toList()
|
||||
)
|
||||
}
|
||||
)
|
||||
subscribedCells = cells
|
||||
}
|
||||
publishPresence()
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentCell(): String? {
|
||||
localLocationCell?.takeIf(::isValidGeohash)?.let { return it.take(CELL_PRECISION) }
|
||||
return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION)
|
||||
}
|
||||
|
||||
private fun availableBridgePeer(): VerifiedBridgePeer? =
|
||||
verifiedPeers.values.firstOrNull { peer ->
|
||||
peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true &&
|
||||
peer.bridgeCell != null &&
|
||||
currentMesh()?.getPeerInfo(peer.peerId)?.isConnected == true
|
||||
}
|
||||
|
||||
private fun handleRendezvousEvent(event: NostrEvent) {
|
||||
if (!_isEnabled.value) return
|
||||
val cell = event.tagValue("r") ?: return
|
||||
if (cell !in subscribedCells || publishedEventIds.contains(event.id)) return
|
||||
if (isOwnEvent(event, cell)) {
|
||||
publishedEventIds.add(event.id)
|
||||
return
|
||||
}
|
||||
if (!allowSignatureAttempt() || !event.isValidSignature()) return
|
||||
if (!receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
|
||||
if (!isFresh(event) || event.tagValue("r") != cell || !isValidGeohash(cell)) return
|
||||
|
||||
when (event.kind) {
|
||||
NostrKind.GEOHASH_PRESENCE -> recordParticipant(event.pubkey, null)
|
||||
NostrKind.EPHEMERAL_EVENT -> {
|
||||
val message = classifyMessage(event, cell) ?: return
|
||||
val localRadio = message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true
|
||||
if (!localRadio && injectBridgeMessage(message)) {
|
||||
recordParticipant(event.pubkey, event.tagValue("n"))
|
||||
}
|
||||
if (!localRadio &&
|
||||
!meshBroadcastEventIds.contains(event.id) &&
|
||||
!rebroadcastEventIds.contains(event.id) &&
|
||||
pendingDownlinks.none { it.event.id == event.id }
|
||||
) {
|
||||
pendingDownlinks += PendingDownlink(cell, event)
|
||||
while (pendingDownlinks.size > MAX_PENDING_DOWNLINKS) pendingDownlinks.removeAt(0)
|
||||
scheduleDownlink(jitter = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUplink(carrier: NostrCarrierPacket, depositor: String) {
|
||||
if (!_isEnabled.value || !allowUplink(depositor)) return
|
||||
val event = structurallyValidEvent(carrier) ?: return
|
||||
if (meshBroadcastEventIds.contains(event.id) ||
|
||||
publishedEventIds.contains(event.id) ||
|
||||
queuedUplinks.any { it.event.id == event.id }
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!allowSignatureAttempt() || !event.isValidSignature()) return
|
||||
if (relayManager?.isConnected?.value == true) {
|
||||
publishCarriedEvent(event, carrier.geohash)
|
||||
} else {
|
||||
if (queuedUplinks.count { it.depositor == depositor } >= MAX_UPLINKS_PER_DEPOSITOR) return
|
||||
queuedUplinks += PendingUplink(depositor, carrier.geohash, event)
|
||||
while (queuedUplinks.size > MAX_QUEUED_UPLINKS) queuedUplinks.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownlink(carrier: NostrCarrierPacket) {
|
||||
val event = structurallyValidEvent(carrier) ?: return
|
||||
if (publishedEventIds.contains(event.id) || isOwnEvent(event, carrier.geohash)) return
|
||||
if (!allowSignatureAttempt() || !event.isValidSignature()) return
|
||||
val firstMesh = meshBroadcastEventIds.add(event.id)
|
||||
if (!firstMesh || !receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
|
||||
val message = classifyMessage(event, carrier.geohash) ?: return
|
||||
if (injectBridgeMessage(message)) recordParticipant(event.pubkey, event.tagValue("n"))
|
||||
}
|
||||
|
||||
private fun structurallyValidEvent(carrier: NostrCarrierPacket): NostrEvent? {
|
||||
if (!isValidGeohash(carrier.geohash) ||
|
||||
carrier.eventJson.size > NostrCarrierPacket.MAX_EVENT_JSON_BYTES
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val event = carrier.event() ?: return null
|
||||
if (!isFresh(event) || event.tagValue("r") != carrier.geohash) return null
|
||||
return when (event.kind) {
|
||||
NostrKind.GEOHASH_PRESENCE -> event
|
||||
NostrKind.EPHEMERAL_EVENT ->
|
||||
event.takeIf { classifyMessage(it, carrier.geohash) != null }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyMessage(event: NostrEvent, cell: String): BitchatMessage? {
|
||||
if (event.kind != NostrKind.EPHEMERAL_EVENT ||
|
||||
!isFresh(event) ||
|
||||
event.tagValue("r") != cell ||
|
||||
!isValidGeohash(cell)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val content = event.content
|
||||
if (content.isBlank() || content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return null
|
||||
val nickname = event.tagValue("n")?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val m = event.tags.firstOrNull { it.size >= 4 && it[0] == "m" }
|
||||
val radioHint = if (m != null &&
|
||||
m[2].matches(Regex("^[0-9a-fA-F]{16}$"))
|
||||
) {
|
||||
m[3].toLongOrNull()?.let { MeshMessageIdentity.stableId(m[2], it, content) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return BitchatMessage(
|
||||
id = event.id,
|
||||
sender = "${nickname ?: "anon"}#${event.pubkey.takeLast(4)}",
|
||||
content = content,
|
||||
timestamp = Date(event.createdAt * 1000L),
|
||||
senderPeerID = "bridge:${event.pubkey.take(16)}",
|
||||
isBridged = true,
|
||||
bridgeRadioMessageIdHint = radioHint
|
||||
)
|
||||
}
|
||||
|
||||
private fun injectBridgeMessage(message: BitchatMessage): Boolean {
|
||||
if (!injectedEventIds.add(message.id)) return false
|
||||
if (message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) return false
|
||||
AppStateStore.addPublicMessage(message)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun radioCopyPresent(messageId: String): Boolean =
|
||||
radioMessageIds.contains(messageId) || AppStateStore.hasRadioPublicMessage(messageId)
|
||||
|
||||
private fun scheduleDownlink(jitter: Boolean) {
|
||||
if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return
|
||||
val now = clock()
|
||||
downlinkTimes.removeAll { now - it >= 60_000 }
|
||||
val waitMs = if (jitter) {
|
||||
jitter(200, 1_501)
|
||||
} else {
|
||||
(downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50)
|
||||
}
|
||||
downlinkJob = scope.launch {
|
||||
delay(waitMs)
|
||||
drainDownlinks()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainDownlinks() {
|
||||
val now = clock()
|
||||
downlinkTimes.removeAll { now - it >= 60_000 }
|
||||
while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) {
|
||||
val item = pendingDownlinks.removeAt(0)
|
||||
if (!isFresh(item.event) ||
|
||||
meshBroadcastEventIds.contains(item.event.id) ||
|
||||
rebroadcastEventIds.contains(item.event.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val message = classifyMessage(item.event, item.cell)
|
||||
if (message?.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) continue
|
||||
val payload = NostrCarrierPacket.fromEvent(
|
||||
NostrCarrierPacket.Direction.FROM_BRIDGE,
|
||||
item.cell,
|
||||
item.event
|
||||
)?.encode() ?: continue
|
||||
currentMesh()?.sendNostrCarrier(payload)
|
||||
rebroadcastEventIds.add(item.event.id)
|
||||
downlinkTimes += clock()
|
||||
}
|
||||
if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false)
|
||||
}
|
||||
|
||||
private fun flushQueuedUplinks() {
|
||||
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
|
||||
val queued = queuedUplinks.toList()
|
||||
queuedUplinks.clear()
|
||||
queued.filterNot { publishedEventIds.contains(it.event.id) }
|
||||
.forEach { publishCarriedEvent(it.event, it.cell) }
|
||||
}
|
||||
|
||||
private fun publishCarriedEvent(event: NostrEvent, cell: String) {
|
||||
publishedEventIds.add(event.id)
|
||||
relayManager?.sendEventToGeohash(event, cell)
|
||||
}
|
||||
|
||||
private fun publishPresence() {
|
||||
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
|
||||
val cell = _activeCell.value ?: return
|
||||
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
val event = NostrProtocol.createBridgePresenceEvent(cell, identity)
|
||||
publishedEventIds.add(event.id)
|
||||
relayManager?.sendEventToGeohash(event, cell)
|
||||
}
|
||||
|
||||
private fun startPresenceLoop() {
|
||||
if (presenceJob?.isActive == true) return
|
||||
presenceJob = scope.launch {
|
||||
while (true) {
|
||||
delay(PRESENCE_INTERVAL_MS)
|
||||
pruneParticipants()
|
||||
if (_isEnabled.value) {
|
||||
LocationChannelManager.getInstance(requireContext())
|
||||
.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous()
|
||||
publishPresence()
|
||||
broadcastPrekeys()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPresenceLoop() {
|
||||
presenceJob?.cancel()
|
||||
presenceJob = null
|
||||
}
|
||||
|
||||
fun refreshPrekeys() {
|
||||
scope.launch { broadcastPrekeys(force = true) }
|
||||
}
|
||||
|
||||
private fun broadcastPrekeys(force: Boolean = false) {
|
||||
if (suppressPrekeyBroadcasts) return
|
||||
prekeyCoordinator?.broadcast(force)
|
||||
}
|
||||
|
||||
private fun recordParticipant(pubkey: String, nickname: String?) {
|
||||
val now = clock()
|
||||
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
|
||||
if (pubkey !in participants && participants.size >= MAX_PARTICIPANTS) {
|
||||
participants.minByOrNull { it.value.lastSeenMs }?.key?.let(participants::remove)
|
||||
}
|
||||
val previous = participants[pubkey]
|
||||
participants[pubkey] = BridgedParticipant(
|
||||
pubkey,
|
||||
nickname?.trim()?.takeIf { it.isNotEmpty() } ?: previous?.nickname,
|
||||
now
|
||||
)
|
||||
publishParticipants()
|
||||
}
|
||||
|
||||
private fun pruneParticipants() {
|
||||
val now = clock()
|
||||
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
|
||||
publishParticipants()
|
||||
}
|
||||
|
||||
private fun publishParticipants() {
|
||||
_bridgedParticipants.value = participants.values.sortedByDescending { it.lastSeenMs }
|
||||
}
|
||||
|
||||
private fun allowUplink(depositor: String): Boolean {
|
||||
val now = clock()
|
||||
val times = uplinkTimes.getOrPut(depositor) { mutableListOf() }
|
||||
times.removeAll { now - it >= 60_000 }
|
||||
if (times.size >= UPLINKS_PER_MINUTE_PER_DEPOSITOR) return false
|
||||
times += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun allowInbound(signer: String): Boolean {
|
||||
val now = clock()
|
||||
inboundTimes.removeAll { now - it >= 60_000 }
|
||||
if (inboundTimes.size >= INBOUND_PER_MINUTE) return false
|
||||
val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() }
|
||||
signerTimes.removeAll { now - it >= 60_000 }
|
||||
if (signerTimes.size >= INBOUND_PER_SIGNER_PER_MINUTE) return false
|
||||
inboundTimes += now
|
||||
signerTimes += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun allowSignatureAttempt(): Boolean {
|
||||
val now = clock()
|
||||
signatureAttemptTimes.removeAll { now - it >= 60_000 }
|
||||
if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false
|
||||
signatureAttemptTimes += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun closeSubscriptions() {
|
||||
relayManager?.let { manager ->
|
||||
rendezvousSubscription.close(manager::unsubscribe)
|
||||
}
|
||||
subscribedCells = emptySet()
|
||||
}
|
||||
|
||||
private fun isOwnEvent(event: NostrEvent, cell: String): Boolean =
|
||||
runCatching {
|
||||
NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
.publicKeyHex.equals(event.pubkey, ignoreCase = true)
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun isFresh(event: NostrEvent): Boolean =
|
||||
abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS
|
||||
|
||||
private fun isValidGeohash(value: String): Boolean =
|
||||
value.length in 1..12 &&
|
||||
value.matches(Regex("^[0123456789bcdefghjkmnpqrstuvwxyz]+$", RegexOption.IGNORE_CASE))
|
||||
|
||||
private fun NostrEvent.tagValue(name: String): String? =
|
||||
tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
|
||||
|
||||
private fun currentMesh(): MeshService? = meshProvider()
|
||||
|
||||
private fun requireContext(): Context =
|
||||
checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" }
|
||||
|
||||
private fun loadEnabledWithMigration(
|
||||
preferences: android.content.SharedPreferences
|
||||
): Boolean {
|
||||
if (preferences.contains(KEY_ENABLED)) return preferences.getBoolean(KEY_ENABLED, false)
|
||||
val legacyKeys = listOf("gateway_user_enabled", "gateway_enabled", "gateway.userEnabled")
|
||||
val migrated = legacyKeys.any { preferences.getBoolean(it, false) }
|
||||
preferences.edit {
|
||||
putBoolean(KEY_ENABLED, migrated)
|
||||
legacyKeys.forEach(::remove)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
|
||||
/**
|
||||
* Coordinates authenticated prekey packets without owning cryptographic
|
||||
* persistence. [PrekeyManager] remains the repository/crypto boundary.
|
||||
*/
|
||||
internal class PrekeyCoordinator(
|
||||
private val manager: PrekeyManager,
|
||||
private val meshProvider: () -> MeshService?,
|
||||
private val peersProvider: () -> List<VerifiedBridgePeer>,
|
||||
private val clock: () -> Long = System::currentTimeMillis
|
||||
) {
|
||||
private val pendingPackets = linkedMapOf<String, BitchatPacket>()
|
||||
private var lastBroadcastMs = 0L
|
||||
|
||||
fun handlePacket(packet: BitchatPacket) {
|
||||
val bundle = PrekeyBundle.decode(packet.payload) ?: return
|
||||
val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey)
|
||||
if (owner != packet.senderID.toHex()) return
|
||||
val peer = peersProvider().firstOrNull { it.peerId == owner }
|
||||
if (peer == null || !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)) {
|
||||
if (pendingPackets.size < MAX_PENDING_PACKETS || owner in pendingPackets) {
|
||||
pendingPackets[owner] = packet
|
||||
}
|
||||
return
|
||||
}
|
||||
ingestVerified(packet, bundle, peer)
|
||||
}
|
||||
|
||||
fun handlePeerVerified(peerId: String) {
|
||||
pendingPackets.remove(peerId)?.let(::handlePacket)
|
||||
}
|
||||
|
||||
fun broadcast(force: Boolean = false) {
|
||||
val now = clock()
|
||||
if (!force && now - lastBroadcastMs < REBROADCAST_INTERVAL_MS) return
|
||||
val bundle = manager.currentSignedBundle(now) ?: return
|
||||
val encoded = bundle.encode() ?: return
|
||||
lastBroadcastMs = now
|
||||
meshProvider()?.sendPrekeyBundle(encoded)
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
pendingPackets.clear()
|
||||
lastBroadcastMs = 0L
|
||||
manager.wipe()
|
||||
}
|
||||
|
||||
private fun ingestVerified(
|
||||
packet: BitchatPacket,
|
||||
bundle: PrekeyBundle,
|
||||
peer: VerifiedBridgePeer
|
||||
) {
|
||||
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return
|
||||
if (manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())) {
|
||||
com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private companion object {
|
||||
const val MAX_PENDING_PACKETS = 64
|
||||
const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,331 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.noise.CourierNoiseCrypto
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Owns local one-time prekeys and verified peer bundles for courier v2.
|
||||
*
|
||||
* Local private keys use EncryptedSharedPreferences through
|
||||
* [SecureIdentityStateManager]. Peer bundles contain public material only,
|
||||
* but their consumption assignments are persisted so retries of one message
|
||||
* never spend additional prekeys.
|
||||
*/
|
||||
class PrekeyManager internal constructor(
|
||||
private val identity: PrekeyIdentity,
|
||||
private val localStore: LocalPrekeyStore,
|
||||
private val peerStore: PeerPrekeyStore,
|
||||
private val randomBytes: () -> ByteArray
|
||||
) {
|
||||
data class Sealed(
|
||||
val ciphertext: ByteArray,
|
||||
val prekeyId: Long?
|
||||
)
|
||||
|
||||
data class Opened(
|
||||
val payload: ByteArray,
|
||||
val senderStaticKey: ByteArray,
|
||||
val consumedPrekey: Boolean
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private var local: LocalPrekeyState? = null
|
||||
private var peerBundles: MutableMap<String, StoredPeerPrekeyBundle>? = null
|
||||
|
||||
fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) {
|
||||
val staticKey = identity.staticKey()?.second ?: return@synchronized null
|
||||
val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null
|
||||
val state = loadLocalLocked()
|
||||
replenishLocked(state, nowMs)
|
||||
val prekeys = state.records
|
||||
.asSequence()
|
||||
.filter { it.consumedAt == null }
|
||||
.sortedBy { it.id }
|
||||
.mapNotNull { record ->
|
||||
decode(record.privateKey)?.let { privateKey ->
|
||||
PrekeyBundle.Prekey(record.id, CourierNoiseCrypto.publicKey(privateKey))
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
if (prekeys.isEmpty()) return@synchronized null
|
||||
|
||||
val unsigned = PrekeyBundle(
|
||||
noiseStaticPublicKey = staticKey,
|
||||
prekeys = prekeys,
|
||||
generatedAt = state.generatedAt,
|
||||
signature = ByteArray(PrekeyBundle.SIGNATURE_LENGTH)
|
||||
)
|
||||
val signature = signEd25519(unsigned.signableBytes(), signingPrivateKey) ?: return@synchronized null
|
||||
unsigned.copy(signature = signature)
|
||||
}
|
||||
|
||||
fun verifyAndIngest(
|
||||
bundle: PrekeyBundle,
|
||||
expectedNoiseKey: ByteArray,
|
||||
announceBoundSigningKey: ByteArray,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Boolean {
|
||||
if (!bundle.noiseStaticPublicKey.contentEquals(expectedNoiseKey) ||
|
||||
announceBoundSigningKey.size != PrekeyBundle.KEY_LENGTH ||
|
||||
!verifyEd25519(bundle.signature, bundle.signableBytes(), announceBoundSigningKey)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
synchronized(lock) {
|
||||
val bundles = loadPeerBundlesLocked()
|
||||
val key = encode(bundle.noiseStaticPublicKey)
|
||||
val existing = bundles[key]
|
||||
if (existing != null && existing.generatedAt >= bundle.generatedAt) return false
|
||||
|
||||
val freshIds = bundle.prekeys.map { it.id }.toSet()
|
||||
bundles[key] = StoredPeerPrekeyBundle(
|
||||
noiseKey = key,
|
||||
generatedAt = bundle.generatedAt,
|
||||
prekeyIds = bundle.prekeys.map { it.id },
|
||||
prekeyPublicKeys = bundle.prekeys.map { encode(it.publicKey) },
|
||||
usedIds = existing?.usedIds?.filterTo(mutableSetOf()) { it in freshIds } ?: mutableSetOf(),
|
||||
assignments = existing?.assignments
|
||||
?.filterValues { it in freshIds }
|
||||
?.toMutableMap() ?: mutableMapOf(),
|
||||
updatedAt = nowMs
|
||||
)
|
||||
while (bundles.size > MAX_PEERS) {
|
||||
bundles.minByOrNull { it.value.updatedAt }?.key?.let(bundles::remove)
|
||||
}
|
||||
persistPeerBundlesLocked(bundles)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun seal(
|
||||
payload: ByteArray,
|
||||
messageId: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
recipientAdvertisesPrekeys: Boolean,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Sealed {
|
||||
val senderPrivateKey = identity.staticKey()?.first
|
||||
?: throw IllegalStateException("Noise static identity is unavailable")
|
||||
val assigned = if (recipientAdvertisesPrekeys) {
|
||||
assignPrekey(messageId, recipientNoiseKey, nowMs)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return if (assigned != null) {
|
||||
Sealed(
|
||||
CourierNoiseCrypto.sealToPrekey(payload, senderPrivateKey, assigned),
|
||||
assigned.id
|
||||
)
|
||||
} else {
|
||||
Sealed(
|
||||
CourierNoiseCrypto.seal(payload, senderPrivateKey, recipientNoiseKey),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun open(
|
||||
ciphertext: ByteArray,
|
||||
prekeyId: Long?,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Opened {
|
||||
if (prekeyId == null) {
|
||||
val staticPrivateKey = identity.staticKey()?.first
|
||||
?: throw IllegalStateException("Noise static identity is unavailable")
|
||||
val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey)
|
||||
return Opened(opened.payload, opened.senderStaticKey, false)
|
||||
}
|
||||
|
||||
synchronized(lock) {
|
||||
val state = loadLocalLocked()
|
||||
pruneLocked(state, nowMs)
|
||||
val record = state.records.firstOrNull { it.id == prekeyId }
|
||||
?: throw IllegalArgumentException("Unknown or expired courier prekey")
|
||||
val consumedAt = record.consumedAt
|
||||
if (consumedAt != null && nowMs - consumedAt > CONSUMED_GRACE_MS) {
|
||||
throw IllegalArgumentException("Courier prekey grace window expired")
|
||||
}
|
||||
val privateKey = decode(record.privateKey)
|
||||
?: throw IllegalArgumentException("Invalid courier prekey")
|
||||
val opened = CourierNoiseCrypto.openWithPrekey(ciphertext, privateKey, prekeyId)
|
||||
val newlyConsumed = record.consumedAt == null
|
||||
if (newlyConsumed) {
|
||||
record.consumedAt = nowMs
|
||||
advanceGeneratedAtLocked(state, nowMs)
|
||||
replenishLocked(state, nowMs)
|
||||
persistLocalLocked(state)
|
||||
}
|
||||
return Opened(opened.payload, opened.senderStaticKey, newlyConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasUsableBundle(
|
||||
recipientNoiseKey: ByteArray,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Boolean = synchronized(lock) {
|
||||
val bundle = loadPeerBundlesLocked()[encode(recipientNoiseKey)] ?: return@synchronized false
|
||||
isFresh(bundle, nowMs) && bundle.prekeyIds.any { it !in bundle.usedIds }
|
||||
}
|
||||
|
||||
fun wipe() = synchronized(lock) {
|
||||
local = LocalPrekeyState()
|
||||
peerBundles = mutableMapOf()
|
||||
localStore.clear()
|
||||
peerStore.clear()
|
||||
}
|
||||
|
||||
private fun assignPrekey(
|
||||
messageId: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
nowMs: Long
|
||||
): PrekeyBundle.Prekey? = synchronized(lock) {
|
||||
val bundles = loadPeerBundlesLocked()
|
||||
val key = encode(recipientNoiseKey)
|
||||
val bundle = bundles[key] ?: return@synchronized null
|
||||
if (!isFresh(bundle, nowMs)) return@synchronized null
|
||||
|
||||
bundle.assignments[messageId]?.let { assigned ->
|
||||
val index = bundle.prekeyIds.indexOf(assigned)
|
||||
if (index >= 0) {
|
||||
return@synchronized decode(bundle.prekeyPublicKeys[index])
|
||||
?.let { PrekeyBundle.Prekey(assigned, it) }
|
||||
}
|
||||
}
|
||||
|
||||
val index = bundle.prekeyIds.indices
|
||||
.filter { bundle.prekeyIds[it] !in bundle.usedIds }
|
||||
.minByOrNull { bundle.prekeyIds[it] }
|
||||
?: return@synchronized null
|
||||
val id = bundle.prekeyIds[index]
|
||||
val publicKey = decode(bundle.prekeyPublicKeys[index]) ?: return@synchronized null
|
||||
bundle.usedIds += id
|
||||
bundle.assignments[messageId] = id
|
||||
bundle.updatedAt = nowMs
|
||||
persistPeerBundlesLocked(bundles)
|
||||
PrekeyBundle.Prekey(id, publicKey)
|
||||
}
|
||||
|
||||
private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean {
|
||||
val beforeRecords = state.records.size
|
||||
val beforeUnconsumed = state.records.count { it.consumedAt == null }
|
||||
pruneLocked(state, nowMs)
|
||||
val unconsumed = state.records.count { it.consumedAt == null }
|
||||
var changed = unconsumed != beforeUnconsumed
|
||||
if (unconsumed < REPLENISH_THRESHOLD) {
|
||||
repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) {
|
||||
val privateKey = randomBytes()
|
||||
require(privateKey.size == PrekeyBundle.KEY_LENGTH)
|
||||
state.records += LocalPrekeyRecord(
|
||||
id = state.nextId and 0xFFFF_FFFFL,
|
||||
privateKey = encode(privateKey),
|
||||
createdAt = nowMs
|
||||
)
|
||||
state.nextId = (state.nextId + 1) and 0xFFFF_FFFFL
|
||||
}
|
||||
advanceGeneratedAtLocked(state, nowMs)
|
||||
changed = true
|
||||
}
|
||||
if (changed || state.records.size != beforeRecords) persistLocalLocked(state)
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) {
|
||||
state.records.removeAll { record ->
|
||||
record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS }
|
||||
?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) {
|
||||
state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1)
|
||||
}
|
||||
|
||||
private fun loadLocalLocked(): LocalPrekeyState {
|
||||
local?.let { return it }
|
||||
val loaded = localStore.load()
|
||||
local = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private fun persistLocalLocked(state: LocalPrekeyState) {
|
||||
try { localStore.save(state) } catch (error: Exception) {
|
||||
local = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPeerBundlesLocked(): MutableMap<String, StoredPeerPrekeyBundle> {
|
||||
peerBundles?.let { return it }
|
||||
return peerStore.load().also { peerBundles = it }
|
||||
}
|
||||
|
||||
private fun persistPeerBundlesLocked(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
try { peerStore.save(bundles) } catch (error: Exception) {
|
||||
peerBundles = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean =
|
||||
nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS
|
||||
|
||||
private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(true, Ed25519PrivateKeyParameters(privateKey, 0))
|
||||
update(data, 0, data.size)
|
||||
}.generateSignature()
|
||||
}.getOrNull()
|
||||
|
||||
private fun verifyEd25519(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(false, Ed25519PublicKeyParameters(publicKey, 0))
|
||||
update(data, 0, data.size)
|
||||
}.verifySignature(signature)
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun encode(data: ByteArray): String =
|
||||
Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
|
||||
private fun decode(value: String): ByteArray? =
|
||||
runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull()
|
||||
|
||||
companion object {
|
||||
private const val PEER_PREFS = "bitchat_prekey_bundles"
|
||||
private const val REPLENISH_THRESHOLD = 3
|
||||
private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000
|
||||
private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000
|
||||
private const val MAX_BUNDLE_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private const val MAX_PEERS = 200
|
||||
|
||||
@Volatile
|
||||
private var instance: PrekeyManager? = null
|
||||
|
||||
fun getInstance(context: Context): PrekeyManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: run {
|
||||
val application = context.applicationContext
|
||||
val identityState = SecureIdentityStateManager(application)
|
||||
val random = SecureRandom()
|
||||
PrekeyManager(
|
||||
identity = AndroidPrekeyIdentity(identityState),
|
||||
localStore = SecureLocalPrekeyStore(identityState),
|
||||
peerStore = SharedPreferencesPeerPrekeyStore(
|
||||
application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE)
|
||||
),
|
||||
randomBytes = {
|
||||
ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes)
|
||||
}
|
||||
).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
internal data class LocalPrekeyRecord(
|
||||
val id: Long,
|
||||
val privateKey: String,
|
||||
val createdAt: Long,
|
||||
var consumedAt: Long? = null
|
||||
)
|
||||
|
||||
internal data class LocalPrekeyState(
|
||||
var records: MutableList<LocalPrekeyRecord> = mutableListOf(),
|
||||
var nextId: Long = 0,
|
||||
var generatedAt: Long = 0
|
||||
)
|
||||
|
||||
internal data class StoredPeerPrekeyBundle(
|
||||
val noiseKey: String,
|
||||
var generatedAt: Long,
|
||||
var prekeyIds: List<Long>,
|
||||
var prekeyPublicKeys: List<String>,
|
||||
var usedIds: MutableSet<Long>,
|
||||
var assignments: MutableMap<String, Long>,
|
||||
var updatedAt: Long
|
||||
)
|
||||
|
||||
internal interface PrekeyIdentity {
|
||||
fun staticKey(): Pair<ByteArray, ByteArray>?
|
||||
fun signingKey(): Pair<ByteArray, ByteArray>?
|
||||
}
|
||||
|
||||
internal interface LocalPrekeyStore {
|
||||
fun load(): LocalPrekeyState
|
||||
fun save(state: LocalPrekeyState)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal interface PeerPrekeyStore {
|
||||
fun load(): MutableMap<String, StoredPeerPrekeyBundle>
|
||||
fun save(bundles: Map<String, StoredPeerPrekeyBundle>)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal class AndroidPrekeyIdentity(
|
||||
private val state: SecureIdentityStateManager
|
||||
) : PrekeyIdentity {
|
||||
override fun staticKey(): Pair<ByteArray, ByteArray>? = state.loadStaticKey()
|
||||
override fun signingKey(): Pair<ByteArray, ByteArray>? = state.loadSigningKey()
|
||||
}
|
||||
|
||||
internal class SecureLocalPrekeyStore(
|
||||
private val state: SecureIdentityStateManager,
|
||||
private val gson: Gson = Gson()
|
||||
) : LocalPrekeyStore {
|
||||
override fun load(): LocalPrekeyState =
|
||||
runCatching {
|
||||
state.getSecureValue(LOCAL_STORE_KEY)
|
||||
?.let { gson.fromJson(it, LocalPrekeyState::class.java) }
|
||||
}.getOrNull() ?: LocalPrekeyState()
|
||||
|
||||
override fun save(state: LocalPrekeyState) {
|
||||
check(this.state.storeSecureValueSynchronously(LOCAL_STORE_KEY, gson.toJson(state))) {
|
||||
"Unable to persist courier prekeys"
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
check(state.clearSecureValuesSynchronously(LOCAL_STORE_KEY))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LocalPrekeyStore"
|
||||
const val LOCAL_STORE_KEY = "courier_prekeys_v1"
|
||||
}
|
||||
}
|
||||
|
||||
internal class SharedPreferencesPeerPrekeyStore(
|
||||
private val preferences: SharedPreferences,
|
||||
private val gson: Gson = Gson()
|
||||
) : PeerPrekeyStore {
|
||||
override fun load(): MutableMap<String, StoredPeerPrekeyBundle> {
|
||||
val type = object : TypeToken<List<StoredPeerPrekeyBundle>>() {}.type
|
||||
val values: List<StoredPeerPrekeyBundle> = runCatching {
|
||||
preferences.getString(PEER_BUNDLES_KEY, null)
|
||||
?.let { json -> gson.fromJson<List<StoredPeerPrekeyBundle>>(json, type) }
|
||||
}.getOrNull() ?: emptyList()
|
||||
return values
|
||||
.filter { it.prekeyIds.size == it.prekeyPublicKeys.size }
|
||||
.associateByTo(mutableMapOf()) { it.noiseKey }
|
||||
}
|
||||
|
||||
override fun save(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
check(preferences.edit().putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())).commit())
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
check(preferences.edit().clear().commit())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PEER_BUNDLES_KEY = "bundles_v1"
|
||||
}
|
||||
}
|
||||
@ -133,7 +133,7 @@ class GossipSyncManager(
|
||||
// Only ANNOUNCE or broadcast MESSAGE
|
||||
val mt = MessageType.fromValue(packet.type)
|
||||
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
|
||||
val isGroupMessage = mt == MessageType.GROUP_MESSAGE &&
|
||||
val isGroupMessage = mt in setOf(MessageType.GROUP_MESSAGE, MessageType.PREKEY_BUNDLE) &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
@ -149,7 +149,8 @@ class GossipSyncManager(
|
||||
val age = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
|
||||
synchronized(groupMessages) {
|
||||
groupMessages[id] = packet
|
||||
val cacheKey = if (mt == MessageType.PREKEY_BUNDLE) "prekey-${packet.senderID.toHexString()}" else id
|
||||
groupMessages[cacheKey] = packet
|
||||
while (groupMessages.size > 200) {
|
||||
val iterator = groupMessages.entries.iterator()
|
||||
iterator.next(); iterator.remove()
|
||||
@ -204,7 +205,7 @@ class GossipSyncManager(
|
||||
SyncTypeFlags.PUBLIC_MESSAGES,
|
||||
SyncTypeFlags.FRAGMENT,
|
||||
SyncTypeFlags.FILE_TRANSFER,
|
||||
SyncTypeFlags.GROUP_MESSAGE
|
||||
SyncTypeFlags.GROUP_MESSAGE.union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
|
||||
).forEach { types ->
|
||||
val payload = buildGcsPayload(types)
|
||||
val packet = BitchatPacket(
|
||||
@ -226,7 +227,7 @@ class GossipSyncManager(
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String, requestedTypes: SyncTypeFlags? = null) {
|
||||
val types = requestedTypes ?: SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
|
||||
.union(SyncTypeFlags.GROUP_MESSAGE)
|
||||
.union(SyncTypeFlags.GROUP_MESSAGE).union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
|
||||
.let { if (boardPacketsProvider != null && peerSupportsBoard(peerID)) it.union(SyncTypeFlags.BOARD) else it }
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
@ -286,8 +287,8 @@ class GossipSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedTypes.contains(MessageType.GROUP_MESSAGE)) {
|
||||
synchronized(groupMessages) { groupMessages.values.toList() }.forEach { packet ->
|
||||
if (requestedTypes.contains(MessageType.GROUP_MESSAGE) || requestedTypes.contains(MessageType.PREKEY_BUNDLE)) {
|
||||
typedSyncCandidates(requestedTypes).forEach { packet ->
|
||||
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
|
||||
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
|
||||
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
|
||||
@ -314,8 +315,19 @@ class GossipSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun typedSyncCandidates(types: SyncTypeFlags): List<BitchatPacket> = synchronized(groupMessages) {
|
||||
val now = System.currentTimeMillis()
|
||||
groupMessages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS }
|
||||
groupMessages.values.filter { packet ->
|
||||
MessageType.fromValue(packet.type)?.let(types::contains) == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRespondTo(peerID: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
responseTimesByPeer.entries.removeIf { (_, times) ->
|
||||
synchronized(times) { times.isEmpty() || now - times.last() >= 30_000L }
|
||||
}
|
||||
if (responseTimesByPeer.size >= 256 && !responseTimesByPeer.containsKey(peerID)) return false
|
||||
val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() }
|
||||
return synchronized(times) {
|
||||
@ -360,9 +372,7 @@ class GossipSyncManager(
|
||||
// Collect only the packet types represented by this filter.
|
||||
val list = ArrayList<BitchatPacket>()
|
||||
if (types.contains(MessageType.BOARD_POST)) list.addAll(boardPacketsProvider?.invoke().orEmpty().take(200))
|
||||
if (types.contains(MessageType.GROUP_MESSAGE)) {
|
||||
synchronized(groupMessages) { list.addAll(groupMessages.values) }
|
||||
}
|
||||
list.addAll(typedSyncCandidates(types))
|
||||
if (types.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
|
||||
@ -6,7 +6,7 @@ import com.bitchat.android.protocol.MessageType
|
||||
@JvmInline
|
||||
value class SyncTypeFlags private constructor(val rawValue: ULong) {
|
||||
companion object {
|
||||
private const val KNOWN_TYPE_MASK: ULong = 0x5ffu
|
||||
private const val KNOWN_TYPE_MASK: ULong = 0x7ffu
|
||||
|
||||
val BOARD = fromMessageTypes(MessageType.BOARD_POST)
|
||||
val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE)
|
||||
@ -48,7 +48,9 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) {
|
||||
MessageType.REQUEST_SYNC -> 6
|
||||
MessageType.FILE_TRANSFER -> 7
|
||||
MessageType.BOARD_POST -> 8
|
||||
MessageType.PREKEY_BUNDLE -> 9
|
||||
MessageType.GROUP_MESSAGE -> 10
|
||||
MessageType.NOSTR_CARRIER,
|
||||
MessageType.COURIER_ENVELOPE,
|
||||
MessageType.VOICE_FRAME -> null
|
||||
}
|
||||
|
||||
@ -303,11 +303,13 @@ private fun SettingsToggleRow(
|
||||
fun AboutSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onShowDebug: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
bridgeEnabled: Boolean,
|
||||
onBridgeEnabledChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowDebug: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
// Get version name from package info
|
||||
val versionName = remember {
|
||||
try {
|
||||
@ -544,6 +546,19 @@ fun AboutSheet(
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Public,
|
||||
title = stringResource(R.string.mesh_bridge_title),
|
||||
subtitle = stringResource(R.string.mesh_bridge_description),
|
||||
checked = bridgeEnabled,
|
||||
onCheckedChange = onBridgeEnabledChange
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
@ -1303,7 +1318,7 @@ fun PasswordPromptDialog(
|
||||
) {
|
||||
if (show && channelName != null) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
@ -1321,7 +1336,7 @@ fun PasswordPromptDialog(
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
OutlinedTextField(
|
||||
value = passwordInput,
|
||||
onValueChange = onPasswordChange,
|
||||
|
||||
@ -564,6 +564,7 @@ fun NicknameEditor(
|
||||
@Composable
|
||||
fun PeerCounter(
|
||||
connectedPeers: List<String>,
|
||||
bridgedPeopleCount: Int = 0,
|
||||
joinedChannels: Set<String>,
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
isConnected: Boolean,
|
||||
@ -586,8 +587,8 @@ fun PeerCounter(
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
||||
val count = connectedPeers.size
|
||||
Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary)
|
||||
val count = connectedPeers.size + bridgedPeopleCount
|
||||
Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) colorScheme.secondary else palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@ -727,6 +728,7 @@ private fun MainHeader(
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val crowdingMode = headerCrowdingMode(maxWidth)
|
||||
@ -809,6 +811,7 @@ private fun MainHeader(
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
bridgedPeopleCount = bridgeUiState.participants.size,
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
|
||||
@ -83,6 +83,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@ -443,7 +444,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = showMediaButtons
|
||||
showMediaButtons = showMediaButtons,
|
||||
showBridgeControls = bridgeUiState.enabled &&
|
||||
currentChannel == null &&
|
||||
selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location,
|
||||
nearbyOnly = bridgeUiState.nearbyOnly,
|
||||
onNearbyOnlyChange = viewModel::setBridgeNearbyOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -645,7 +651,10 @@ fun ChatInputSection(
|
||||
colorScheme: ColorScheme,
|
||||
showMediaButtons: Boolean,
|
||||
recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showBridgeControls: Boolean = false,
|
||||
nearbyOnly: Boolean = false,
|
||||
onNearbyOnlyChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val activePublicTalker by remember(context) {
|
||||
@ -726,6 +735,9 @@ fun ChatInputSection(
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
showBridgeControls = showBridgeControls,
|
||||
nearbyOnly = nearbyOnly,
|
||||
onNearbyOnlyChange = onNearbyOnlyChange,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
recorderFactory = recorderFactory,
|
||||
activePublicTalker = activePublicTalker,
|
||||
@ -840,6 +852,7 @@ private fun ChatDialogs(
|
||||
onMeshPeerListDismiss: () -> Unit,
|
||||
) {
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@ -856,7 +869,9 @@ private fun ChatDialogs(
|
||||
AboutSheet(
|
||||
isPresented = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss,
|
||||
onShowDebug = { showDebugSheet = true }
|
||||
onShowDebug = { showDebugSheet = true },
|
||||
bridgeEnabled = bridgeUiState.enabled,
|
||||
onBridgeEnabledChange = viewModel::setBridgeEnabled
|
||||
)
|
||||
if (showDebugSheet) {
|
||||
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
||||
|
||||
@ -40,6 +40,8 @@ import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import com.bitchat.android.services.bridge.BridgeUiState
|
||||
import com.bitchat.android.services.bridge.MeshBridgeService
|
||||
import com.bitchat.android.features.voice.LiveVoicePreferences
|
||||
import com.bitchat.android.features.voice.LiveVoiceTarget
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
@ -616,6 +618,21 @@ class ChatViewModel(
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val bridgeUiState: StateFlow<BridgeUiState> = combine(
|
||||
MeshBridgeService.isEnabled,
|
||||
MeshBridgeService.nearbyOnly,
|
||||
MeshBridgeService.bridgedParticipants
|
||||
) { enabled, nearbyOnly, participants ->
|
||||
BridgeUiState(enabled, nearbyOnly, participants)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = BridgeUiState(
|
||||
enabled = MeshBridgeService.isEnabled.value,
|
||||
nearbyOnly = MeshBridgeService.nearbyOnly.value,
|
||||
participants = MeshBridgeService.bridgedParticipants.value
|
||||
)
|
||||
)
|
||||
val meshServiceFacade: MeshService
|
||||
get() = mesh
|
||||
val myPeerID: String
|
||||
@ -1566,6 +1583,14 @@ class ChatViewModel(
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
|
||||
fun setBridgeEnabled(enabled: Boolean) {
|
||||
MeshBridgeService.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setBridgeNearbyOnly(enabled: Boolean) {
|
||||
MeshBridgeService.setNearbyOnly(enabled)
|
||||
}
|
||||
|
||||
fun hideMeshPeerList() {
|
||||
state.setShowMeshPeerList(false)
|
||||
}
|
||||
@ -1718,6 +1743,7 @@ class ChatViewModel(
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
// become actionable against the fresh post-wipe identity.
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
MeshBridgeService.wipe()
|
||||
|
||||
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
|
||||
// any transport callback already in flight until the fresh identity is ready.
|
||||
@ -1763,7 +1789,7 @@ class ChatViewModel(
|
||||
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
locationManager.clearPersistedChannel()
|
||||
locationManager.panicReset()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
geohashViewModel.panicReset()
|
||||
|
||||
@ -14,9 +14,21 @@ class DataManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DataManager"
|
||||
private const val PREFS_NAME = "bitchat_prefs"
|
||||
private const val BLOCKED_USERS_KEY = "blocked_users"
|
||||
|
||||
/**
|
||||
* Reads the current persisted block list for ingress paths that have a
|
||||
* full Noise key but no live mesh peer/session.
|
||||
*/
|
||||
fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getStringSet(BLOCKED_USERS_KEY, emptySet())
|
||||
?.contains(fingerprint) == true
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
// Channel-related maps that need to persist state
|
||||
@ -200,13 +212,13 @@ class DataManager(private val context: Context) {
|
||||
|
||||
@Synchronized
|
||||
fun loadBlockedUsers() {
|
||||
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
|
||||
val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet()
|
||||
_blockedUsers.clear()
|
||||
_blockedUsers.addAll(savedBlockedUsers)
|
||||
}
|
||||
|
||||
fun saveBlockedUsers() {
|
||||
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
|
||||
prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply()
|
||||
}
|
||||
|
||||
fun addBlockedUser(fingerprint: String) {
|
||||
|
||||
@ -320,7 +320,10 @@ fun MessageInput(
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
recorderFactory: ((String?, String?) -> VoiceRecorder)? = null,
|
||||
activePublicTalker: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showBridgeControls: Boolean = false,
|
||||
nearbyOnly: Boolean = false,
|
||||
onNearbyOnlyChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@ -405,6 +408,25 @@ fun MessageInput(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (showBridgeControls) {
|
||||
IconToggleButton(
|
||||
checked = nearbyOnly,
|
||||
onCheckedChange = onNearbyOnlyChange,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (nearbyOnly) Icons.Filled.Bluetooth else Icons.Filled.Public,
|
||||
contentDescription = if (nearbyOnly) {
|
||||
stringResource(R.string.cd_nearby_only_on)
|
||||
} else {
|
||||
stringResource(R.string.cd_nearby_only_off)
|
||||
},
|
||||
tint = if (nearbyOnly) Color(0xFFFF9500) else Color(0xFF00A7C4),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The pill. Field and action buttons are one visual object.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@ -160,6 +160,8 @@ class MeshDelegateHandler(
|
||||
|
||||
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
|
||||
coroutineScope.launch {
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()
|
||||
?.onMessageAcknowledged(messageID, recipientPeerID)
|
||||
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,7 @@ import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.services.bridge.BridgedParticipant
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
@ -111,6 +112,7 @@ fun MeshPeerListSheet(
|
||||
val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle()
|
||||
val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val geohashPeopleCount = geohashPeople.size
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
val directPeerIdentityIDs = remember(peerDirect) {
|
||||
@ -451,6 +453,13 @@ fun MeshPeerListSheet(
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
if (bridgeUiState.enabled && bridgeUiState.participants.isNotEmpty()) {
|
||||
BridgedPeopleSection(
|
||||
participants = bridgeUiState.participants,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -687,6 +696,53 @@ private fun GroupSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgedPeopleSection(
|
||||
participants: List<BridgedParticipant>,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.across_bridge).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
participants.forEach { participant ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF00A7C4),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = participant.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.via_mesh_bridge),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.55f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channel: String,
|
||||
|
||||
@ -1372,6 +1372,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendMessage(content, mentions, channel)
|
||||
}
|
||||
|
||||
override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
|
||||
meshCore.sendNostrCarrier(payload, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
meshCore.sendCourierEnvelope(payload, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun sendPrekeyBundle(payload: ByteArray) {
|
||||
meshCore.sendPrekeyBundle(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a private encrypted message to a specific peer.
|
||||
*
|
||||
|
||||
@ -611,6 +611,13 @@
|
||||
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
|
||||
<string name="select">Select</string>
|
||||
<string name="type_a_message_placeholder">Type a message…</string>
|
||||
<string name="mesh_bridge_title" tools:ignore="MissingTranslation">mesh bridge</string>
|
||||
<string name="mesh_bridge_description" tools:ignore="MissingTranslation">share public nearby messages through relays and carry encrypted offline messages for others</string>
|
||||
<string name="across_bridge" tools:ignore="MissingTranslation">across the bridge</string>
|
||||
<string name="via_mesh_bridge" tools:ignore="MissingTranslation">via mesh bridge</string>
|
||||
<string name="cd_mesh_bridge_active" tools:ignore="MissingTranslation">Mesh bridge active</string>
|
||||
<string name="cd_nearby_only_on" tools:ignore="MissingTranslation">Nearby only: messages stay within radio range</string>
|
||||
<string name="cd_nearby_only_off" tools:ignore="MissingTranslation">Bridged: messages also reach people across the bridge</string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="mention">Mention</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
|
||||
@ -0,0 +1,163 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.noise.CourierNoiseCrypto
|
||||
import com.bitchat.android.nostr.MeshMessageIdentity
|
||||
import com.bitchat.android.nostr.NostrIdentity
|
||||
import com.bitchat.android.nostr.NostrKind
|
||||
import com.bitchat.android.nostr.NostrProtocol
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BridgeProtocolInteropTest {
|
||||
@Test
|
||||
fun `carrier TLVs match the iOS wire fixture`() {
|
||||
val carrier = NostrCarrierPacket(
|
||||
direction = NostrCarrierPacket.Direction.TO_BRIDGE,
|
||||
geohash = "u4pruy",
|
||||
eventJson = "{}".toByteArray()
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"010001030200067534707275790300027b7d",
|
||||
carrier.encode().toHex()
|
||||
)
|
||||
assertEquals(carrier, NostrCarrierPacket.decode(carrier.encode()))
|
||||
assertNull(NostrCarrierPacket.decode(carrier.encode().dropLast(1).toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `carrier decoder skips unknown TLVs`() {
|
||||
val encoded = NostrCarrierPacket(
|
||||
NostrCarrierPacket.Direction.FROM_BRIDGE,
|
||||
"u4pruy",
|
||||
"{}".toByteArray()
|
||||
).encode()
|
||||
val withUnknown = encoded + byteArrayOf(0x7F, 0x00, 0x02, 0x12, 0x34)
|
||||
|
||||
assertEquals(
|
||||
NostrCarrierPacket.Direction.FROM_BRIDGE,
|
||||
NostrCarrierPacket.decode(withUnknown)?.direction
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `courier envelope and daily tag match iOS fixtures`() {
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = ByteArray(16) { it.toByte() },
|
||||
expiry = 0x0102_0304_0506_0708uL,
|
||||
ciphertext = byteArrayOf(0xAA.toByte(), 0xBB.toByte()),
|
||||
copies = 3u,
|
||||
prekeyID = 0x89AB_CDEFu
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"010010000102030405060708090a0b0c0d0e0f" +
|
||||
"0200080102030405060708" +
|
||||
"030002aabb04000103" +
|
||||
"05000489abcdef",
|
||||
envelope.encode()!!.toHex()
|
||||
)
|
||||
assertEquals(envelope, CourierEnvelope.decode(envelope.encode()!!))
|
||||
assertEquals(
|
||||
"f7b87836e588a2b31b306605b3313744",
|
||||
CourierEnvelope.recipientTag(ByteArray(32) { it.toByte() }, 1u).toHex()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signed prekey canonical bytes match iOS fixture`() {
|
||||
val bundle = PrekeyBundle(
|
||||
noiseStaticPublicKey = ByteArray(32) { 0x11 },
|
||||
prekeys = listOf(
|
||||
PrekeyBundle.Prekey(0x0102_0304, ByteArray(32) { 0x22 })
|
||||
),
|
||||
generatedAt = 0x0102_0304_0506_0708L,
|
||||
signature = ByteArray(64) { 0x33 }
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"18" +
|
||||
"626974636861742d7072656b65792d62756e646c652d7631" +
|
||||
"11".repeat(32) +
|
||||
"01" +
|
||||
"01020304" +
|
||||
"22".repeat(32) +
|
||||
"0102030405060708",
|
||||
bundle.signableBytes().toHex()
|
||||
)
|
||||
assertEquals(bundle, PrekeyBundle.decode(bundle.encode()!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `courier Noise X opens static and one-time prekey ciphertexts`() {
|
||||
val senderPrivate = ByteArray(32) { (it + 1).toByte() }
|
||||
val recipientPrivate = ByteArray(32) { (it + 33).toByte() }
|
||||
val payload = "offline hello".toByteArray()
|
||||
|
||||
val staticCiphertext = CourierNoiseCrypto.seal(
|
||||
payload,
|
||||
senderPrivate,
|
||||
CourierNoiseCrypto.publicKey(recipientPrivate)
|
||||
)
|
||||
val staticOpened = CourierNoiseCrypto.open(staticCiphertext, recipientPrivate)
|
||||
assertArrayEquals(payload, staticOpened.payload)
|
||||
assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), staticOpened.senderStaticKey)
|
||||
|
||||
val prekey = PrekeyBundle.Prekey(
|
||||
id = 0x89AB_CDEFL,
|
||||
publicKey = CourierNoiseCrypto.publicKey(recipientPrivate)
|
||||
)
|
||||
val prekeyCiphertext = CourierNoiseCrypto.sealToPrekey(payload, senderPrivate, prekey)
|
||||
val prekeyOpened = CourierNoiseCrypto.openWithPrekey(
|
||||
prekeyCiphertext,
|
||||
recipientPrivate,
|
||||
prekey.id
|
||||
)
|
||||
assertArrayEquals(payload, prekeyOpened.payload)
|
||||
assertArrayEquals(CourierNoiseCrypto.publicKey(senderPrivate), prekeyOpened.senderStaticKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public message stable identity matches cross-language fixture`() {
|
||||
val id = MeshMessageIdentity.stableId(
|
||||
senderIdHex = "0011223344556677",
|
||||
timestampMs = 1_750_000_000_123,
|
||||
content = "hello mesh"
|
||||
)
|
||||
|
||||
assertEquals("b83f94d81dcdd1b0c0048f6645995dd4", id)
|
||||
assertTrue(id.all { it in '0'..'9' || it in 'a'..'f' })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bridge Nostr event uses signed rendezvous tags`() {
|
||||
val identity = NostrIdentity.fromPrivateKey("01".padStart(64, '0'))
|
||||
val event = NostrProtocol.createBridgeMeshEvent(
|
||||
content = "hello mesh",
|
||||
cell = "u4pruy",
|
||||
senderIdentity = identity,
|
||||
nickname = "alice",
|
||||
meshSenderId = "0011223344556677",
|
||||
meshTimestampMs = 1_750_000_000_123
|
||||
)
|
||||
|
||||
assertEquals(NostrKind.EPHEMERAL_EVENT, event.kind)
|
||||
assertEquals(listOf("r", "u4pruy"), event.tags[0])
|
||||
assertEquals(listOf("n", "alice"), event.tags[1])
|
||||
assertEquals(
|
||||
listOf(
|
||||
"m",
|
||||
"b83f94d81dcdd1b0c0048f6645995dd4",
|
||||
"0011223344556677",
|
||||
"1750000000123"
|
||||
),
|
||||
event.tags[2]
|
||||
)
|
||||
assertTrue(event.isValidSignature())
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -67,6 +67,18 @@ class IdentityAnnouncementTest {
|
||||
assertEquals(PeerCapabilities.NONE, decoded.capabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized bridge cell is omitted without dropping announcement`() {
|
||||
val encoded = IdentityAnnouncement(
|
||||
nickname,
|
||||
noiseKey,
|
||||
signingKey,
|
||||
bridgeGeohash = "u".repeat(13)
|
||||
).encode()!!
|
||||
|
||||
assertNull(IdentityAnnouncement.decode(encoded)?.bridgeGeohash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown capability bits and TLVs survive decode and re-encode`() {
|
||||
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
|
||||
@ -92,12 +104,13 @@ class IdentityAnnouncementTest {
|
||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x05, 0x02, 0x38, 0x01),
|
||||
byteArrayOf(0x05, 0x02, 0x39, 0x01),
|
||||
encoded.takeLast(4).toByteArray()
|
||||
)
|
||||
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!
|
||||
assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA))
|
||||
assertTrue(capabilities.contains(PeerCapabilities.GROUPS))
|
||||
assertTrue(capabilities.contains(PeerCapabilities.VOUCH))
|
||||
assertTrue(capabilities.contains(PeerCapabilities.PREKEYS))
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class Tlv16CodecTest {
|
||||
@Test
|
||||
fun `codec preserves ordered fields and unknown types`() {
|
||||
val encoded = requireNotNull(
|
||||
Tlv16Codec.encode(
|
||||
Tlv16Codec.Field(1, byteArrayOf(1, 2)),
|
||||
Tlv16Codec.Field(0x7F, byteArrayOf(3))
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = requireNotNull(Tlv16Codec.decode(encoded))
|
||||
assertEquals(listOf(1, 0x7F), decoded.map { it.type })
|
||||
assertArrayEquals(byteArrayOf(1, 2), decoded[0].value)
|
||||
assertArrayEquals(byteArrayOf(3), decoded[1].value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `codec rejects truncated framing and oversized values`() {
|
||||
assertNull(Tlv16Codec.decode(byteArrayOf(1, 0)))
|
||||
assertNull(Tlv16Codec.decode(byteArrayOf(1, 0, 2, 1)))
|
||||
assertNull(Tlv16Codec.encode(Tlv16Codec.Field(1, ByteArray(0x1_0000))))
|
||||
}
|
||||
}
|
||||
@ -75,17 +75,38 @@ class NostrProtocolTest {
|
||||
assertTrue(event.isValidSignature())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMalformedAuthenticatedEnvelopes() {
|
||||
val sender = NostrIdentity.generate()
|
||||
val recipient = NostrIdentity.generate()
|
||||
val malformed = listOf(
|
||||
forgedGiftWrap("hello", sender, sender, recipient, rumorKind = NostrKind.TEXT_NOTE),
|
||||
forgedGiftWrap("hello", sender, sender, recipient, rumorTags = listOf(listOf("p", sender.publicKeyHex))),
|
||||
forgedGiftWrap("hello", sender, sender, recipient, sealTags = listOf(listOf("p", recipient.publicKeyHex))),
|
||||
forgedGiftWrap("hello", sender, sender, recipient, rumorTimestamp = 1),
|
||||
forgedGiftWrap("hello", sender, sender, recipient, outerTags = emptyList())
|
||||
)
|
||||
malformed.forEach { assertNull(NostrProtocol.decryptPrivateMessage(it, recipient)) }
|
||||
val ios = forgedGiftWrap("hello", sender, sender, recipient, rumorTags = emptyList())
|
||||
assertEquals("hello", NostrProtocol.decryptPrivateMessage(ios, recipient)?.first)
|
||||
}
|
||||
|
||||
private fun forgedGiftWrap(
|
||||
content: String,
|
||||
claimedSender: NostrIdentity,
|
||||
sealSigner: NostrIdentity,
|
||||
recipient: NostrIdentity
|
||||
recipient: NostrIdentity,
|
||||
rumorKind: Int = NostrKind.DIRECT_MESSAGE,
|
||||
rumorTags: List<List<String>> = listOf(listOf("p", recipient.publicKeyHex)),
|
||||
sealTags: List<List<String>> = emptyList(),
|
||||
rumorTimestamp: Int = (System.currentTimeMillis() / 1000).toInt(),
|
||||
outerTags: List<List<String>> = listOf(listOf("p", recipient.publicKeyHex))
|
||||
): NostrEvent {
|
||||
val rumorBase = NostrEvent(
|
||||
pubkey = claimedSender.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DIRECT_MESSAGE,
|
||||
tags = listOf(listOf("p", recipient.publicKeyHex)),
|
||||
createdAt = rumorTimestamp,
|
||||
kind = rumorKind,
|
||||
tags = rumorTags,
|
||||
content = content
|
||||
)
|
||||
val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex())
|
||||
@ -98,7 +119,7 @@ class NostrProtocolTest {
|
||||
pubkey = sealSigner.publicKeyHex,
|
||||
createdAt = NostrCrypto.randomizeTimestampUpToPast(),
|
||||
kind = NostrKind.SEAL,
|
||||
tags = emptyList(),
|
||||
tags = sealTags,
|
||||
content = sealContent
|
||||
).sign(sealSigner.privateKeyHex)
|
||||
|
||||
@ -112,7 +133,7 @@ class NostrProtocolTest {
|
||||
pubkey = wrapPublicKey,
|
||||
createdAt = NostrCrypto.randomizeTimestampUpToPast(),
|
||||
kind = NostrKind.GIFT_WRAP,
|
||||
tags = listOf(listOf("p", recipient.publicKeyHex)),
|
||||
tags = outerTags,
|
||||
content = giftWrapContent
|
||||
).sign(wrapPrivateKey)
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.mesh.BridgeOutboundPolicy
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BridgeRaceRegressionTest {
|
||||
@Test
|
||||
fun `later opt in cannot authorize a send that was previously denied`() {
|
||||
val policyAtSend = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false)
|
||||
val policyAtPublish = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
|
||||
|
||||
assertFalse(policyAtSend.permitsPublication(policyAtPublish))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later opt out still blocks a send that was previously allowed`() {
|
||||
val policyAtSend = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
|
||||
val policyAtPublish = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false)
|
||||
|
||||
assertFalse(policyAtSend.permitsPublication(policyAtPublish))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `publication requires bridge permission at send and publish time`() {
|
||||
val allowed = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
|
||||
val nearbyOnly = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = true)
|
||||
|
||||
assertTrue(allowed.permitsPublication(allowed))
|
||||
assertFalse(nearbyOnly.permitsPublication(allowed))
|
||||
assertFalse(allowed.permitsPublication(nearbyOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delayed close targets the retired subscription generation`() {
|
||||
val slot = RelaySubscriptionSlot("bridge")
|
||||
val activeOnRelay = mutableSetOf<String>()
|
||||
val delayedCloses = mutableListOf<String>()
|
||||
|
||||
val first = slot.replace(
|
||||
close = delayedCloses::add,
|
||||
open = activeOnRelay::add
|
||||
)
|
||||
val second = slot.replace(
|
||||
close = delayedCloses::add,
|
||||
open = activeOnRelay::add
|
||||
)
|
||||
|
||||
delayedCloses.forEach(activeOnRelay::remove)
|
||||
|
||||
assertNotEquals(first, second)
|
||||
assertFalse(first in activeOnRelay)
|
||||
assertTrue(second in activeOnRelay)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.noise.CourierNoiseCrypto
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PrekeyManagerTest {
|
||||
@Test
|
||||
fun `message retry reuses assigned prekey and recipient grace key`() {
|
||||
val now = 1_750_000_000_000L
|
||||
val recipientIdentity = identity(seed = 1)
|
||||
val senderIdentity = identity(seed = 65)
|
||||
var generatedSeed = 100
|
||||
val recipient = manager(recipientIdentity) {
|
||||
ByteArray(32) { index -> (generatedSeed + index).toByte() }
|
||||
.also { generatedSeed += 1 }
|
||||
}
|
||||
val sender = manager(senderIdentity)
|
||||
val bundle = requireNotNull(recipient.currentSignedBundle(now))
|
||||
|
||||
assertTrue(
|
||||
sender.verifyAndIngest(
|
||||
bundle,
|
||||
expectedNoiseKey = requireNotNull(recipientIdentity.staticKey()).second,
|
||||
announceBoundSigningKey = requireNotNull(recipientIdentity.signingKey()).second,
|
||||
nowMs = now
|
||||
)
|
||||
)
|
||||
|
||||
val payload = "offline hello".toByteArray()
|
||||
val first = sender.seal(
|
||||
payload,
|
||||
messageId = "message-1",
|
||||
recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second,
|
||||
recipientAdvertisesPrekeys = true,
|
||||
nowMs = now
|
||||
)
|
||||
val retry = sender.seal(
|
||||
payload,
|
||||
messageId = "message-1",
|
||||
recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second,
|
||||
recipientAdvertisesPrekeys = true,
|
||||
nowMs = now + 1
|
||||
)
|
||||
|
||||
assertNotNull(first.prekeyId)
|
||||
assertEquals(first.prekeyId, retry.prekeyId)
|
||||
val firstOpened = recipient.open(first.ciphertext, first.prekeyId, now + 2)
|
||||
val retryOpened = recipient.open(retry.ciphertext, retry.prekeyId, now + 3)
|
||||
assertArrayEquals(payload, firstOpened.payload)
|
||||
assertArrayEquals(payload, retryOpened.payload)
|
||||
assertTrue(firstOpened.consumedPrekey)
|
||||
assertFalse(retryOpened.consumedPrekey)
|
||||
|
||||
val nextMessage = sender.seal(
|
||||
payload,
|
||||
messageId = "message-2",
|
||||
recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second,
|
||||
recipientAdvertisesPrekeys = true,
|
||||
nowMs = now + 4
|
||||
)
|
||||
assertNotEquals(first.prekeyId, nextMessage.prekeyId)
|
||||
}
|
||||
|
||||
private fun manager(
|
||||
identity: PrekeyIdentity,
|
||||
randomBytes: () -> ByteArray = { ByteArray(32) { (it + 11).toByte() } }
|
||||
): PrekeyManager =
|
||||
PrekeyManager(
|
||||
identity = identity,
|
||||
localStore = MemoryLocalPrekeyStore(),
|
||||
peerStore = MemoryPeerPrekeyStore(),
|
||||
randomBytes = randomBytes
|
||||
)
|
||||
|
||||
private fun identity(seed: Int): PrekeyIdentity {
|
||||
val staticPrivate = ByteArray(32) { (seed + it).toByte() }
|
||||
val signingPrivate = Ed25519PrivateKeyParameters(
|
||||
ByteArray(32) { (seed + 32 + it).toByte() },
|
||||
0
|
||||
)
|
||||
return FakePrekeyIdentity(
|
||||
static = staticPrivate to CourierNoiseCrypto.publicKey(staticPrivate),
|
||||
signing = signingPrivate.encoded to signingPrivate.generatePublicKey().encoded
|
||||
)
|
||||
}
|
||||
|
||||
private class FakePrekeyIdentity(
|
||||
private val static: Pair<ByteArray, ByteArray>,
|
||||
private val signing: Pair<ByteArray, ByteArray>
|
||||
) : PrekeyIdentity {
|
||||
override fun staticKey(): Pair<ByteArray, ByteArray> = static
|
||||
override fun signingKey(): Pair<ByteArray, ByteArray> = signing
|
||||
}
|
||||
|
||||
private class MemoryLocalPrekeyStore : LocalPrekeyStore {
|
||||
private var state = LocalPrekeyState()
|
||||
|
||||
override fun load(): LocalPrekeyState = state
|
||||
|
||||
override fun save(state: LocalPrekeyState) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
state = LocalPrekeyState()
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryPeerPrekeyStore : PeerPrekeyStore {
|
||||
private var bundles = mutableMapOf<String, StoredPeerPrekeyBundle>()
|
||||
|
||||
override fun load(): MutableMap<String, StoredPeerPrekeyBundle> = bundles
|
||||
|
||||
override fun save(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
this.bundles = bundles.toMutableMap()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
bundles.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,7 @@ class GossipSyncGroupTest {
|
||||
type = type.value,
|
||||
senderID = ByteArray(8) { 0x11 },
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = 1u,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 1u
|
||||
)
|
||||
|
||||
@ -22,7 +22,7 @@ class SyncTypeFlagsTest {
|
||||
|
||||
@Test
|
||||
fun `unknown extended bits are normalized away`() {
|
||||
val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xfc.toByte()))!!
|
||||
val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xf8.toByte()))!!
|
||||
|
||||
assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, decoded)
|
||||
assertArrayEquals(byteArrayOf(0x03), decoded.encode())
|
||||
|
||||
@ -81,6 +81,8 @@ composeCompiler {
|
||||
// onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice
|
||||
// features, and the phone's foreground service.
|
||||
val sharedSourceIncludes = listOf(
|
||||
"com/bitchat/android/nostr/MeshMessageIdentity.kt",
|
||||
"com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt",
|
||||
"com/bitchat/android/board/BoardPackets.kt",
|
||||
"com/bitchat/android/board/BoardStore.kt",
|
||||
"com/bitchat/android/protocol/**",
|
||||
@ -114,6 +116,7 @@ val sharedSourceIncludes = listOf(
|
||||
"com/bitchat/android/util/BinaryEncodingUtils.kt",
|
||||
)
|
||||
val sharedSourceExcludes = listOf(
|
||||
"com/bitchat/android/model/NostrCarrierPacket.kt",
|
||||
"com/bitchat/android/model/FileSharingManager.kt",
|
||||
// Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service
|
||||
// (MeshCore-style) in M2 instead of reusing these.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user