Merge pull request #787 from permissionlesstech/fix/noise-after-Prs

Restore robust Noise handshakes and mesh DMs
This commit is contained in:
callebtc 2026-07-27 18:49:54 +02:00 committed by GitHub
commit 90b00ac557
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 353 additions and 446 deletions

View File

@ -1,14 +0,0 @@
package com.bitchat.android.mesh
/**
* Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that
* authentication attempt.
*/
internal object AuthenticatedBleLinkPolicy {
data class Claim(val deviceAddress: String, val linkID: String)
fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean =
claim != null &&
claim.deviceAddress == authenticatedAddress &&
claim.linkID == authenticatedLinkID
}

View File

@ -92,8 +92,8 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID)
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID)
fun getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)

View File

@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
private val peerBindingLock = Any()
private val connectionStateLock = Any()
/**
* Consolidated device connection information
@ -77,9 +77,9 @@ class BluetoothConnectionTracker(
*/
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
synchronized(peerBindingLock) {
synchronized(connectionStateLock) {
connectedDevices[deviceAddress] = deviceConn
// A mapping authenticates a GATT connection, not a reusable Bluetooth address.
// A route observation belongs to this GATT generation, not its reusable address.
addressPeerMap.remove(deviceAddress)
}
removePendingConnection(deviceAddress)
@ -91,7 +91,7 @@ class BluetoothConnectionTracker(
* Update a device connection
*/
fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
synchronized(peerBindingLock) {
synchronized(connectionStateLock) {
connectedDevices[deviceAddress] = deviceConn
}
}
@ -100,7 +100,7 @@ class BluetoothConnectionTracker(
deviceAddress: String,
linkID: String,
update: (DeviceConnection) -> DeviceConnection
): Boolean = synchronized(peerBindingLock) {
): Boolean = synchronized(connectionStateLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != linkID) return@synchronized false
connectedDevices[deviceAddress] = update(current)
@ -117,10 +117,16 @@ class BluetoothConnectionTracker(
fun getCurrentLinkID(deviceAddress: String): String? =
connectedDevices[deviceAddress]?.linkID
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
synchronized(peerBindingLock) {
/**
* Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID].
*
* A peer may be reachable over more than one link, so observing one link must not discard the
* other observations. The link generation check prevents a late packet from an old GATT
* connection from being applied to a replacement connection that reused the same address.
*/
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
synchronized(connectionStateLock) {
if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false
addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress }
addressPeerMap[deviceAddress] = peerID
true
}
@ -265,7 +271,7 @@ class BluetoothConnectionTracker(
* Clean up a specific device connection
*/
fun cleanupDeviceConnection(deviceAddress: String) {
synchronized(peerBindingLock) {
synchronized(connectionStateLock) {
connectedDevices.remove(deviceAddress)
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
@ -277,7 +283,7 @@ class BluetoothConnectionTracker(
fun cleanupDeviceConnectionIfCurrent(
deviceAddress: String,
expectedLinkID: String
): Boolean = synchronized(peerBindingLock) {
): Boolean = synchronized(connectionStateLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != expectedLinkID) {
return@synchronized false

View File

@ -21,7 +21,6 @@ import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.sign
import kotlin.random.Random
@ -43,7 +42,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
companion object {
private const val TAG = "BluetoothMeshService"
private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
}
@ -129,8 +127,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private var announceJob: Job? = null
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
private val provisionalBleClaims =
ConcurrentHashMap<String, AuthenticatedBleLinkPolicy.Claim>()
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
@ -253,7 +249,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
provisionalBleClaims.remove(peerID)
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
// Remove from mesh graph topology to prevent routing through stale peers
@ -282,22 +277,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
val expectedClaim = provisionalBleClaims.remove(peerID)
if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) {
val authenticatedClaim = checkNotNull(expectedClaim)
if (connectionManager.bindPeerIfCurrent(
authenticatedClaim.deviceAddress,
authenticatedClaim.linkID,
peerID
)
) {
Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID")
try { peerManager.refreshPeerList() } catch (_: Exception) { }
try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { }
} else {
Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress")
}
}
// Send announcement and cached messages after key exchange
serviceScope.launch {
delay(100)
@ -572,40 +551,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
val deviceAddress = routed.relayAddress
val pid = routed.peerID
val linkID = routed.ingressLinkID
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
val alreadyAuthenticated = deviceAddress != null &&
pid != null &&
connectionManager.addressPeerMap[deviceAddress] == pid
if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) {
try {
val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)
registerProvisionalBleClaim(pid, claim)
val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true)
if (handshakeData != null) {
val handshake = signPacketBeforeBroadcast(
BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(pid),
timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData,
ttl = MAX_TTL
)
)
if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) {
provisionalBleClaims.remove(pid, claim)
Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress")
}
} else {
provisionalBleClaims.remove(pid, claim)
}
} catch (e: Exception) {
provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID))
Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}")
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation ->
if (connectionManager.observePeerIfCurrent(
observation.relayAddress,
observation.ingressLinkID,
observation.peerID
)
) {
Log.d(
TAG,
"Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}"
)
try { peerManager.refreshPeerList() } catch (_: Exception) { }
try {
gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000)
} catch (_: Exception) { }
} else {
Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}")
}
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
@ -710,13 +672,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
linkID: String?
) {
Log.i(TAG, "Device disconnected: ${device.address}")
val addr = device.address
clearProvisionalBleClaimsForLink(addr, linkID)
// refresh peer list on disconnect.
try { peerManager.refreshPeerList() } catch (_: Exception) { }
// ConnectionTracker already removes an authenticated mapping only when this exact
// ConnectionTracker already removes an observed mapping only when this exact
// link is still current. Do not remove by reusable address here: this may be a late
// disconnect callback from a replaced GATT connection.
}
@ -730,24 +690,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
private fun registerProvisionalBleClaim(
peerID: String,
claim: AuthenticatedBleLinkPolicy.Claim
) {
provisionalBleClaims[peerID] = claim
serviceScope.launch {
delay(BLE_AUTHENTICATION_TIMEOUT_MS)
provisionalBleClaims.remove(peerID, claim)
}
}
private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) {
if (linkID == null) return
provisionalBleClaims.entries.removeIf { (_, claim) ->
claim.deviceAddress == deviceAddress && claim.linkID == linkID
}
}
/**
* Start the mesh service
*/
@ -1023,7 +965,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty()) return
if (recipientNickname.isEmpty()) return
// Nicknames are presentation metadata. Routing and encryption are bound to the peer ID,
// so a temporarily unresolved nickname must never suppress a private message.
serviceScope.launch {
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()

View File

@ -0,0 +1,26 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
/**
* Describes transport reachability learned from an already-validated ANNOUNCE.
*
* This is deliberately only a routing observation. Noise authenticates the peer independently and
* must not be restarted merely to associate the current transport link with that peer.
*/
internal object DirectLinkAnnouncementPolicy {
data class Observation(
val peerID: String,
val relayAddress: String,
val ingressLinkID: String
)
fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? {
if (routed.packet.ttl != maxTtl) return null
return Observation(
peerID = routed.peerID ?: return null,
relayAddress = routed.relayAddress ?: return null,
ingressLinkID = routed.ingressLinkID ?: return null
)
}
}

View File

@ -44,7 +44,6 @@ class MeshCore(
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
val announcementNicknameProvider: (() -> String?)? = null,
@ -156,12 +155,14 @@ class MeshCore(
isActive = false
announceJob?.cancel()
announceJob = null
directPeers.clear()
if (ownsGossipManager) {
gossipSyncManager.stop()
}
}
fun shutdown() {
directPeers.clear()
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@ -215,6 +216,7 @@ class MeshCore(
}
override fun onPeerRemoved(peerID: String) {
directPeers.remove(peerID)
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
@ -235,14 +237,6 @@ class MeshCore(
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke(
peerID,
directRelayAddress,
ingressLinkID,
authenticatedRemoteStaticKey
)
}
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@ -845,6 +839,7 @@ class MeshCore(
}
fun removePeer(peerID: String) {
directPeers.remove(peerID)
peerManager.removePeer(peerID)
}
@ -898,44 +893,6 @@ class MeshCore(
}
}
/**
* Starts a fresh replacement handshake on one exact direct transport generation.
* This authenticates provisional transport claims without broadcasting the challenge or
* accidentally sending it through a socket that later reused the same alias.
*/
fun initiateNoiseHandshakeOnLink(
peerID: String,
relayAddress: String,
ingressLinkID: String
): Boolean {
return try {
val handshakeData = encryptionService.initiateHandshake(
peerID,
replaceEstablished = true
) ?: return false
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData,
ttl = maxTtl
)
transport.sendPacketToLink(
relayAddress,
ingressLinkID,
signPacketBeforeBroadcast(packet)
)
} catch (e: Exception) {
Log.e(
"MeshCore",
"Failed to initiate link-bound Noise handshake with $peerID: ${e.message}"
)
false
}
}
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
@ -989,6 +946,7 @@ class MeshCore(
}
fun clearAllInternalData() {
directPeers.clear()
fragmentManager.clearAllFragments()
storeForwardManager.clearAllCache()
securityManager.clearAllData()

View File

@ -76,7 +76,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
// This ensures we catch the "first announce" on a new connection for binding,
// This ensures we observe the same peer on a new direct transport connection,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS

View File

@ -664,8 +664,11 @@ class ChatViewModel(
}
private fun nicknameForPeer(peerID: String): String? {
return state.peerNicknames.value[peerID]
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
val contact = ContactDirectory.resolve(peerID)
val meshPeerID = contact.meshPeerID ?: peerID
return contact.displayName
?: state.peerNicknames.value[meshPeerID]
?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null }
}
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {

View File

@ -1,39 +0,0 @@
package com.bitchat.android.wifiaware
/**
* Resolves an authenticated callback to the exact still-active ingress link that completed Noise.
* A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
*/
internal object AuthenticatedIngressLinkPolicy {
data class Claim(
val relayAddress: String,
val linkID: String
)
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun matches(
expected: Claim?,
authenticatedRelayAddress: String?,
authenticatedLinkID: String?
): Boolean =
expected != null &&
expected.relayAddress == authenticatedRelayAddress &&
expected.linkID == authenticatedLinkID
fun <T : Any> resolve(
authenticatedLinkID: String?,
authenticatedRelayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = authenticatedLinkID ?: return null
val relayAddress = authenticatedRelayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.wifiaware
/** Resolves a packet to the exact still-active ingress link that delivered it. */
internal object IngressLinkPolicy {
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun <T : Any> resolve(
ingressLinkID: String?,
relayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = ingressLinkID ?: return null
val relayAddress = relayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -100,9 +100,9 @@ class WifiAwareConnectionTracker(
}
/**
* Atomically require that [expectedSocket] is still the active provisional transport and, only
* then, promote it. This closes the gap where a replacement socket could land after validation
* but before mutation and the stale authenticated socket would become canonical.
* Atomically require that [expectedSocket] is still the active provisional transport before
* rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE
* validation but before mutation and the stale socket would become canonical.
*/
fun rebindPeerIdIfCurrent(
previousPeerId: String,

View File

@ -13,6 +13,7 @@ import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
import com.bitchat.android.mesh.FragmentingPacketSender
import com.bitchat.android.mesh.MeshCore
import com.bitchat.android.mesh.MeshService
@ -75,7 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L
private const val CLIENT_SOCKET_ATTEMPTS = 3
private const val CLIENT_ROLE_REVERSAL_FAILURES = 3
private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L
// Discovery freshness window for reconnection maintenance
private const val DISCOVERY_STALE_MS = 5L * 60 * 1000
private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000
@ -125,12 +125,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
IngressLinkPolicy.Link<SyncedSocket>
>()
private val provisionalWifiClaims =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val authenticatedWifiLinks =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained
@ -176,38 +172,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
onMessageReceived = { message -> handleMessageReceived(message) },
onAnnounceProcessed = { routed, _ ->
routed.peerID?.let { pid ->
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
// Discovery IDs from older clients can be provisional. A verified direct
// announce is enough to start a handshake for the canonical ID, but not to
// rebind the socket. A fresh challenge is sent through the exact transport
// generation, and only its same-link completion may promote that alias.
val relay = routed.relayAddress
val linkID = routed.ingressLinkID
if (
routed.packet.ttl == MAX_TTL &&
relay != null &&
linkID != null
) {
val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID)
if (!AuthenticatedIngressLinkPolicy.matches(
authenticatedWifiLinks[pid],
relay,
linkID
)
) {
registerProvisionalWifiClaim(pid, claim)
if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
provisionalWifiClaims.remove(pid, claim)
Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}")
}
}
}
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
?.let(::observeDirectIngressLink)
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
} catch (_: Exception) { }
}
},
onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
},
announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
},
@ -587,8 +558,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
meshCore.shutdown()
@ -634,8 +603,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
}
} finally {
recoveryInProgress = false
@ -901,7 +868,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// presence makes hasOpenServerSocket() true for the life of the process)
// and so we free the fd/port promptly.
connectionTracker.closeServerSocket(peerId)
try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle)
@ -1156,7 +1122,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
activeSocket = synced
connectionTracker.onClientConnected(peerId, synced)
clientSocketFailures.remove(peerId)
try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleServerKeepAlive(synced, peerId, peerHandle)
@ -1235,70 +1200,74 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
/**
* Promote a provisional discovery alias only when the exact, still-active socket delivered the
* Noise frame that completed authentication for the canonical peer ID.
* Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale
* socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is
* not restarted or coupled to this routing observation.
*/
private fun promoteAuthenticatedIngressLink(
canonicalPeerId: String,
relayAddress: String,
ingressLinkID: String
private fun observeDirectIngressLink(
observation: DirectLinkAnnouncementPolicy.Observation
) {
val expectedClaim = provisionalWifiClaims[canonicalPeerId]
if (!AuthenticatedIngressLinkPolicy.matches(
expectedClaim,
relayAddress,
ingressLinkID
)
) {
Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}")
return
}
provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
val link = IngressLinkPolicy.resolve(
ingressLinkID = observation.ingressLinkID,
relayAddress = observation.relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: run {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
Log.d(
TAG,
"Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale"
)
return
}
val provisionalPeerId = link.relayAddress
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
if (existingCanonical == canonicalPeerId) {
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
if (existingCanonical == observation.peerID) {
try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
return
}
if (existingCanonical != provisionalPeerId) {
Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias")
Log.w(
TAG,
"Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias"
)
return
}
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed")
if (!connectionTracker.rebindPeerIdIfCurrent(
provisionalPeerId,
observation.peerID,
link.transport
)
) {
Log.d(
TAG,
"Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed"
)
return
}
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID
}
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it }
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
discoveredTimestamps[canonicalPeerId] = discoveredAt
discoveredTimestamps[observation.peerID] = discoveredAt
try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { }
try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { }
try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { }
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { }
try {
meshCore.addOrUpdatePeer(
observation.peerID,
meshCore.getPeerNickname(observation.peerID) ?: observation.peerID
)
} catch (_: Exception) { }
try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}")
Log.i(
TAG,
"Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}"
)
}
/**
@ -1311,7 +1280,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
val logicalPeerId = initialLogicalPeerId
val ingressLinkID = UUID.randomUUID().toString()
val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket)
ingressLinks[ingressLinkID] = ingressLink
while (isActive) {
val raw = socket.read() ?: break
@ -1326,10 +1295,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue
if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) {
// The socket's discovery identity remains provisional until Noise proves possession
// of the claimed static key on this link. A canonical self-signed announcement is
// only TOFU and cannot safely rebind/remove transport state on its own.
Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof")
// Rebinding happens only after MeshCore validates and accepts this ANNOUNCE.
Log.d(
TAG,
"RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation"
)
}
// Route the packet:
@ -1339,7 +1309,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
ingressLinks.remove(ingressLinkID, ingressLink)
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)")
@ -1347,27 +1316,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
socket.close()
}
private fun registerProvisionalWifiClaim(
peerID: String,
claim: AuthenticatedIngressLinkPolicy.Claim
) {
provisionalWifiClaims[peerID] = claim
serviceScope.launch {
delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
if (provisionalWifiClaims.remove(peerID, claim)) {
Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
} }
}
private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
provisionalWifiClaims.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
authenticatedWifiLinks.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
}
private fun handleNetworkFailure(peerId: String) {
serviceScope.launch {
if (!connectionTracker.isConnected(peerId)) {
@ -1655,9 +1603,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
ingressLinkID: String,
packet: BitchatPacket
): Boolean {
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
val link = IngressLinkPolicy.resolve(
ingressLinkID = ingressLinkID,
relayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: return false

View File

@ -1,40 +0,0 @@
package com.bitchat.android.mesh
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedBleLinkPolicyTest {
private val claim = AuthenticatedBleLinkPolicy.Claim(
deviceAddress = "AA:BB:CC:DD:EE:FF",
linkID = "connection-a"
)
@Test
fun `accepts completion from exact claimed connection`() {
assertTrue(
AuthenticatedBleLinkPolicy.matches(
claim,
authenticatedAddress = claim.deviceAddress,
authenticatedLinkID = claim.linkID
)
)
}
@Test
fun `rejects replacement connection reusing device address`() {
assertFalse(
AuthenticatedBleLinkPolicy.matches(
claim,
authenticatedAddress = claim.deviceAddress,
authenticatedLinkID = "connection-b"
)
)
}
@Test
fun `rejects completion on another address or without a claim`() {
assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID))
assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID))
}
}

View File

@ -14,7 +14,7 @@ import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class BluetoothConnectionTrackerLinkIdentityTest {
class BluetoothConnectionTrackerLinkObservationTest {
private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
@ -46,8 +46,40 @@ class BluetoothConnectionTrackerLinkIdentityTest {
assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a"))
assertEquals("link-b", tracker.getCurrentLinkID(address))
assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677"))
assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677"))
assertEquals("0011223344556677", tracker.addressPeerMap[address])
assertSame(device, tracker.getDeviceConnection(address)?.device)
}
@Test
fun `one peer can remain directly observed over multiple current links`() {
val firstAddress = "AA:BB:CC:DD:EE:01"
val secondAddress = "AA:BB:CC:DD:EE:02"
val firstDevice = mock<BluetoothDevice>()
val secondDevice = mock<BluetoothDevice>()
whenever(firstDevice.address).thenReturn(firstAddress)
whenever(secondDevice.address).thenReturn(secondAddress)
tracker.addDeviceConnection(
firstAddress,
BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a")
)
tracker.addDeviceConnection(
secondAddress,
BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b")
)
assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID })
assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a"))
assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress])
assertTrue(tracker.addressPeerMap.containsValue(PEER_ID))
}
private companion object {
const val PEER_ID = "0011223344556677"
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DirectLinkAnnouncementPolicyTest {
@Test
fun `accepted max ttl announce is a direct routing observation`() {
val routed = announce(ttl = MAX_TTL)
assertEquals(
DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID),
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
)
}
@Test
fun `relayed announce is not a direct routing observation`() {
assertNull(
DirectLinkAnnouncementPolicy.observationFor(
announce(ttl = (MAX_TTL - 1u).toUByte()),
MAX_TTL
)
)
}
@Test
fun `repeated announce remains the same observation without transport authentication state`() {
val routed = announce(ttl = MAX_TTL)
val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
assertEquals(first, second)
}
private fun announce(ttl: UByte) = RoutedPacket(
packet = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = PEER_ID.hexToBytes(),
timestamp = 1u,
payload = byteArrayOf(1),
ttl = ttl
),
peerID = PEER_ID,
relayAddress = RELAY_ADDRESS,
ingressLinkID = LINK_ID
)
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private companion object {
const val PEER_ID = "0011223344556677"
const val RELAY_ADDRESS = "transport-neighbor"
const val LINK_ID = "current-link"
val MAX_TTL: UByte = 7u
}
}

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -104,4 +105,30 @@ class PrivateChatManagerTest {
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
}
@Test
fun `canonical conversation send does not require resolved nickname`() {
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 })
var callbackInvoked = false
manager.sendPrivateMessage(
content = "hello",
peerID = conversationID,
recipientNickname = null,
senderNickname = "bob",
myPeerID = "self"
) { content, recipientID, nickname, _ ->
callbackInvoked = true
assertEquals("hello", content)
assertEquals(conversationID, recipientID)
assertEquals("", nickname)
}
assertTrue(callbackInvoked)
assertEquals(
"hello",
state.getPrivateChatsValue()[conversationID]?.single()?.content
)
}
}

View File

@ -1,93 +0,0 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedIngressLinkPolicyTest {
@Test
fun `promotion claim must match the challenged relay and link`() {
val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link")
assertTrue(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "different-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
expected = null,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
}
@Test
fun `authentication promotes only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "victim-link",
authenticatedRelayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be promoted`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "missing-link",
authenticatedRelayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "completed-link",
authenticatedRelayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "completed-link",
authenticatedRelayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class IngressLinkPolicyTest {
@Test
fun `observation resolves only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = IngressLinkPolicy.resolve(
ingressLinkID = "victim-link",
relayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be observed`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to IngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "missing-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}

View File

@ -17,21 +17,21 @@ import java.net.Socket
class WifiAwareConnectionTrackerTest {
@Test
fun `compare and rebind rejects stale authenticated socket after replacement`() {
fun `compare and rebind rejects stale observed socket after replacement`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()
)
val authenticatedSocket = syncedSocket()
val observedSocket = syncedSocket()
val replacementSocket = syncedSocket()
tracker.onClientConnected("provisional", authenticatedSocket)
tracker.onClientConnected("provisional", observedSocket)
tracker.onClientConnected("provisional", replacementSocket)
assertFalse(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
expectedSocket = authenticatedSocket
expectedSocket = observedSocket
)
)
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
@ -49,7 +49,7 @@ class WifiAwareConnectionTrackerTest {
}
@Test
fun `authenticated provisional socket cannot displace existing canonical socket`() {
fun `observed provisional socket cannot displace existing canonical socket`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()