Merge branch 'codex/bind-noise-peer-identities' into codex/advertise-private-media-capability

# Conflicts:
#	app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
#	app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
#	app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
#	app/src/main/java/com/bitchat/android/model/RoutedPacket.kt
#	app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt
This commit is contained in:
jack
2026-07-12 11:18:17 -04:00
23 changed files with 1416 additions and 277 deletions
@@ -7,6 +7,7 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@@ -290,11 +291,24 @@ open class EncryptionService(private val context: Context) {
Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, peerID)
}
/**
* Process one Noise handshake frame while preserving whether this exact call authenticated a
* new session. Unlike the response-only compatibility API, binding failures are propagated.
*/
@Throws(Exception::class)
open fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
Log.d(TAG, "🤝 Processing typed handshake message from $peerID")
return noiseService.processHandshakeMessageWithResult(data, peerID)
}
/**
* Remove a peer session (called when peer disconnects)
*/
fun removePeer(peerID: String) {
open fun removePeer(peerID: String) {
establishedSessions.remove(peerID)
noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID)
@@ -0,0 +1,37 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */
object AnnouncementIdentityValidator {
private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L
fun verify(
packet: BitchatPacket,
claimedPeerID: String,
nowMs: Long = System.currentTimeMillis(),
verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean
): IdentityAnnouncement? {
if (packet.type != MessageType.ANNOUNCE.value) return null
val now = nowMs.coerceAtLeast(0).toULong()
val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp
if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null
val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null
if (announcement.signingPublicKey.size != 32) return null
val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null
if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null
val signature = packet.signature ?: return null
val canonicalData = packet.toBinaryDataForSigning() ?: return null
return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) {
announcement
} else {
null
}
}
}
@@ -234,8 +234,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// SecurityManager delegate for key exchange notifications
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
override fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
// Send announcement and cached messages after key exchange
serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
@@ -405,31 +409,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
override fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?) {
Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...")
// Update peer mapping in the PeerManager for peer ID rotation support
peerManager.addOrUpdatePeer(newPeerID, nickname)
// Store fingerprint for the peer via centralized fingerprint manager
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
// Index existing Nostr mapping by the new peerID if we have it
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
}
} catch (_: Exception) { }
// If there was a previous peer ID, remove it to avoid duplicates
previousPeerID?.let { oldPeerID ->
peerManager.removePeer(oldPeerID)
}
Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...")
}
// Message operations
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel)
@@ -521,35 +500,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
}
override fun handleAnnounce(routed: RoutedPacket) {
serviceScope.launch {
// Process the announce
val isFirst = messageHandler.handleAnnounce(routed)
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
// Map device address -> peerID based on TTL (max TTL = direct neighbor)
// Matches iOS logic: any announce with max TTL on a link defines the direct peer
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
// Check if this is a direct connection (MAX TTL)
// Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (isDirect) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
// Mark as directly connected - refresh UI state
try { peerManager.refreshPeerList() } catch (_: Exception) { }
// Initial sync for this direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
}
// Map device address -> peerID only after identity binding, signature, freshness,
// and peer replacement policy have all accepted the announce.
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (isDirect) {
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
try { peerManager.refreshPeerList() } catch (_: Exception) { }
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
}
// Track for sync
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
return true
}
override fun handleMessage(routed: RoutedPacket) {
@@ -41,8 +41,8 @@ class MeshCore(
) {
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> 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,
@@ -134,8 +134,20 @@ class MeshCore(
packetProcessor.shutdown()
}
fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
fun processIncoming(
packet: BitchatPacket,
peerID: String?,
relayAddress: String?,
ingressLinkID: String? = null
) {
packetProcessor.processPacket(
RoutedPacket(
packet = packet,
peerID = peerID,
relayAddress = relayAddress,
ingressLinkID = ingressLinkID
)
)
}
fun sendFromBridge(packet: RoutedPacket) {
@@ -174,8 +186,20 @@ class MeshCore(
}
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
override fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke(
peerID,
directRelayAddress,
ingressLinkID,
authenticatedRemoteStaticKey
)
}
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@@ -310,19 +334,6 @@ class MeshCore(
}
}
override fun updatePeerIDBinding(
newPeerID: String,
nickname: String,
publicKey: ByteArray,
previousPeerID: String?
) {
peerManager.addOrUpdatePeer(newPeerID, nickname)
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
previousPeerID?.let { peerManager.removePeer(it) }
Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel)
}
@@ -382,12 +393,12 @@ class MeshCore(
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
}
override fun handleAnnounce(routed: RoutedPacket) {
scope.launch {
val isFirst = messageHandler.handleAnnounce(routed)
hooks.onAnnounceProcessed?.invoke(routed, isFirst)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
hooks.onAnnounceProcessed?.invoke(routed, result.isFirst)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
return true
}
override fun handleMessage(routed: RoutedPacket) {
@@ -3,7 +3,6 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
@@ -13,6 +12,11 @@ import kotlinx.coroutines.*
import java.util.*
import kotlin.random.Random
sealed class AnnounceHandlingResult {
data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult()
object Rejected : AnnounceHandlingResult()
}
/**
* Handles processing of different message types
* Extracted from BluetoothMeshService for better separation of concerns
@@ -216,10 +220,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
* Handle announce message with TLV decoding and signature verification - exactly like iOS
*/
suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
return (handleAnnounceWithResult(routed) as? AnnounceHandlingResult.Accepted)?.isFirst ?: false
}
suspend fun handleAnnounceWithResult(routed: RoutedPacket): AnnounceHandlingResult {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return false
if (peerID == myPeerID) return AnnounceHandlingResult.Rejected
// Peers use wall-clock packet timestamps; tolerate moderate device clock skew
// during identity learning, or later signed messages cannot be verified.
@@ -227,28 +235,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
return false
return AnnounceHandlingResult.Rejected
} else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
}
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
val announcement = IdentityAnnouncement.decode(packet.payload)
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
delegate?.verifyEd25519Signature(signature, data, key) ?: false
}
if (announcement == null) {
Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format")
return false
}
// Verify packet signature using the announced signing public key
var verified = false
if (packet.signature != null) {
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
verified = delegate?.verifyEd25519Signature(packet.signature!!, packet.toBinaryDataForSigning()!!, announcement.signingPublicKey) ?: false
if (!verified) {
Log.w(TAG, "⚠️ Signature verification for announce failed ${peerID.take(8)}")
}
Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}")
return AnnounceHandlingResult.Rejected
}
var verified = true
// Check for existing peer with different noise public key
// If existing peer has a different noise public key, do not consider this verified
val existingPeer = delegate?.getPeerInfo(peerID)
@@ -258,10 +259,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
verified = false
}
if (
existingPeer?.signingPublicKey != null &&
!existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey)
) {
Log.w(
TAG,
"Rejecting signing-key replacement for ${peerID.take(8)} without authenticated peer-state proof"
)
verified = false
}
// Require verified announce; ignore otherwise (no backward compatibility)
if (!verified) {
Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...")
return false
return AnnounceHandlingResult.Rejected
}
// Successfully decoded TLV format exactly like iOS
@@ -284,19 +296,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
capabilities = announcement.capabilities
) ?: false
// Promotion of security-sensitive capabilities happens only after the
// signature check above and must additionally bind to the authenticated
// Noise remote-static key in the transport service.
delegate?.onVerifiedAnnouncementProcessed(peerID)
// Update peer ID binding with noise public key for identity management
delegate?.updatePeerIDBinding(
newPeerID = peerID,
nickname = nickname,
publicKey = noisePublicKey,
previousPeerID = null
)
// Update mesh graph from gossip neighbors (only if TLV present)
try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
@@ -305,7 +304,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
} catch (_: Exception) { }
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
return isFirstAnnounce
return AnnounceHandlingResult.Accepted(isFirstAnnounce)
}
/**
@@ -647,8 +646,6 @@ interface MessageHandlerDelegate {
fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?)
// Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
@@ -143,7 +143,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle public packet types (no address check needed)
when (messageType) {
MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.LEAVE -> handleLeave(routed)
@@ -197,10 +197,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle announce message
*/
private suspend fun handleAnnounce(routed: RoutedPacket) {
private suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}")
delegate?.handleAnnounce(routed)
return delegate?.handleAnnounce(routed) ?: false
}
/**
@@ -231,7 +231,14 @@ class PacketProcessor(private val myPeerID: String) {
val reassembledPacket = delegate?.handleFragment(routed.packet)
if (reassembledPacket != null) {
Log.d(TAG, "Fragment reassembled, processing complete message")
handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress))
handleReceivedPacket(
RoutedPacket(
packet = reassembledPacket,
peerID = routed.peerID,
relayAddress = routed.relayAddress,
ingressLinkID = routed.ingressLinkID
)
)
}
// Fragment relay is now handled by centralized PacketRelayManager
@@ -314,7 +321,7 @@ interface PacketProcessorDelegate {
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket)
fun handleAnnounce(routed: RoutedPacket)
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
@@ -104,19 +104,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Skip our own handshake messages
if (peerID == myPeerID) return false
// If we already have an established session but the peer is initiating a new handshake,
// drop the existing session so we can re-establish cleanly.
var forcedRehandshake = false
if (encryptionService.hasEstablishedSession(peerID)) {
Log.d(TAG, "Received new Noise handshake from $peerID with an existing session. Dropping old session to re-handshake.")
try {
encryptionService.removePeer(peerID)
forcedRehandshake = true
} catch (e: Exception) {
Log.w(TAG, "Failed to remove existing Noise session for $peerID: ${e.message}")
}
}
if (packet.payload.isEmpty()) {
Log.w(TAG, "Noise handshake packet has empty payload")
return false
@@ -125,26 +112,38 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Prevent duplicate handshake processing
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}"
if (!forcedRehandshake && processedKeyExchanges.contains(exchangeKey)) {
if (processedKeyExchanges.contains(exchangeKey)) {
Log.d(TAG, "Already processed handshake: $exchangeKey")
return false
}
Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
processedKeyExchanges.add(exchangeKey)
try {
// Process the Noise handshake through the updated EncryptionService
val response = encryptionService.processHandshakeMessage(packet.payload, peerID)
// The session manager preserves an existing transport in a separate responder-candidate
// flow and reports whether this exact frame completed authentication. Never infer that
// from ambient session state: a rejected replacement may leave the old session active.
val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID)
processedKeyExchanges.add(exchangeKey)
if (response != null) {
if (result.response != null) {
Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response")
// Send handshake response through delegate
delegate?.sendHandshakeResponse(peerID, response)
delegate?.sendHandshakeResponse(peerID, result.response)
}
// Check if session is now established (handshake complete)
if (encryptionService.hasEstablishedSession(peerID)) {
if (result.establishedNow) {
val authenticatedRemoteStaticKey = result.authenticatedRemoteStaticKey
if (authenticatedRemoteStaticKey == null) {
Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key")
return false
}
val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
Log.d(TAG, "✅ Noise handshake completed with $peerID")
delegate?.onKeyExchangeCompleted(peerID, packet.payload)
delegate?.onKeyExchangeCompleted(
peerID = peerID,
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
directRelayAddress = routed.relayAddress.takeIf { isDirectIngress },
ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress }
)
}
return true
@@ -231,14 +230,44 @@ class SecurityManager(private val encryptionService: EncryptionService, private
*/
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try {
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
// Public packets that mutate identity, presence, or user-visible state must prove the
// signing key learned from a verified announcement. LEAVE is included so an attacker
// cannot evict a claimed peer or amplify a forged departure through relay.
if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER
MessageType.FILE_TRANSFER,
MessageType.LEAVE
)) {
return true
}
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
encryptionService.verifyEd25519Signature(signature, data, key)
} ?: run {
Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from $peerID")
return false
}
val existingPeer = delegate?.getPeerInfo(peerID)
if (
existingPeer?.noisePublicKey != null &&
!existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Noise-key replacement for $peerID")
return false
}
if (
existingPeer?.signingPublicKey != null &&
!existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE signing-key replacement without authenticated peer state for $peerID")
return false
}
return true
}
// 1. Mandatory Signature Check
if (packet.signature == null) {
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
@@ -246,21 +275,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
// 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
// Special Case: ANNOUNCE packets carry their own signing key
try {
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
signingPublicKey = announcement?.signingPublicKey
} catch (e: Exception) {
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
}
} else {
// Standard Case: Get key from known peer info
val peerInfo = delegate?.getPeerInfo(peerID)
signingPublicKey = peerInfo?.signingPublicKey
}
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
if (signingPublicKey == null) {
// If we don't have a key (and it's not an announce), we can't verify.
@@ -410,7 +426,12 @@ class SecurityManager(private val encryptionService: EncryptionService, private
* Delegate interface for security manager callbacks
*/
interface SecurityManagerDelegate {
fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray)
fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
)
fun sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification
}
@@ -12,5 +12,8 @@ data class RoutedPacket(
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
val transferId: String? = null, // Optional stable transfer ID for progress tracking
/** Exact fragments admitted during private-media prepare; never rebuild them at commit. */
val preparedPackets: List<BitchatPacket>? = null
val preparedPackets: List<BitchatPacket>? = null,
// Opaque, process-local ingress identity. Unlike relayAddress, this distinguishes replacement
// sockets for the same provisional peer and must never be serialized onto the mesh.
val ingressLinkID: String? = null
)
@@ -194,12 +194,25 @@ class NoiseEncryptionService(private val context: Context) {
*/
fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? {
return try {
sessionManager.processHandshakeMessage(peerID, data)
processHandshakeMessageWithResult(data, peerID).response
} catch (e: Exception) {
Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}")
null
}
}
/**
* Typed handshake result for security-sensitive callers that must distinguish a null response
* from a newly authenticated session or a rejected handshake. Identity mismatch exceptions are
* intentionally propagated to the caller.
*/
@Throws(Exception::class)
fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
return sessionManager.processHandshakeMessageWithResult(peerID, data)
}
/**
* Check if we have an established session with a peer
@@ -382,6 +395,20 @@ class NoiseEncryptionService(private val context: Context) {
// Store fingerprint mapping via centralized manager
// This is the ONLY place where fingerprints are stored - after successful Noise handshake
fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey)
// Preserve the canonical peerID -> npub index, but only after Noise proves possession of
// the static key. Announcement-time indexing was unsafe because a peer can copy another
// party's public Noise key without possessing its private key.
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findNostrPubkey(remoteStaticKey)
?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.updateNostrPublicKeyForPeerID(peerID, npub)
}
} catch (_: Exception) {
// Favorites may not be initialized in isolated/background crypto tests.
}
// Calculate fingerprint for logging and callback
val fingerprint = calculateFingerprint(remoteStaticKey)
@@ -0,0 +1,30 @@
package com.bitchat.android.noise
import java.security.MessageDigest
/**
* Canonical binding between an authenticated Noise static key and its mesh wire identity.
*
* Mesh peer IDs are the first eight bytes of SHA-256(staticPublicKey), encoded as 16 lowercase
* hexadecimal characters. A self-signed announcement or completed Noise XX handshake is not
* sufficient on its own: the claimed wire ID must also match this derivation.
*/
object NoisePeerIdentity {
const val STATIC_PUBLIC_KEY_SIZE = 32
const val WIRE_PEER_ID_LENGTH = 16
private val wirePeerIDPattern = Regex("^[0-9a-f]{$WIRE_PEER_ID_LENGTH}$")
fun derivePeerID(staticPublicKey: ByteArray): String? {
if (staticPublicKey.size != STATIC_PUBLIC_KEY_SIZE) return null
return MessageDigest.getInstance("SHA-256")
.digest(staticPublicKey)
.take(8)
.joinToString("") { "%02x".format(it) }
}
fun matchesClaimedPeerID(claimedPeerID: String, staticPublicKey: ByteArray): Boolean {
if (!wirePeerIDPattern.matches(claimedPeerID)) return false
return derivePeerID(staticPublicKey) == claimedPeerID
}
}
@@ -451,27 +451,33 @@ class NoiseSession(
Log.d(TAG, "Completing XX handshake with $peerID")
try {
// Split handshake state into transport ciphers
val cipherPair = handshakeState?.split()
sendCipher = cipherPair?.getSender()
receiveCipher = cipherPair?.getReceiver()
// Extract remote static key if available
if (handshakeState?.hasRemotePublicKey() == true) {
val remoteDH = handshakeState?.getRemotePublicKey()
if (remoteDH != null) {
remoteStaticPublicKey = ByteArray(32)
remoteDH.getPublicKey(remoteStaticPublicKey!!, 0)
Log.d(TAG, "Remote static public key: ${remoteStaticPublicKey!!.joinToString("") { "%02x".format(it) }}")
}
val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed
// Authenticate the remote static key's claimed mesh identity before split creates
// transport ciphers or the session can become observable as Established.
if (!activeHandshake.hasRemotePublicKey()) throw NoiseSessionError.HandshakeFailed
val remoteDH = activeHandshake.getRemotePublicKey()
?: throw NoiseSessionError.HandshakeFailed
val authenticatedRemoteKey = ByteArray(NoisePeerIdentity.STATIC_PUBLIC_KEY_SIZE)
remoteDH.getPublicKey(authenticatedRemoteKey, 0)
val derivedPeerID = NoisePeerIdentity.derivePeerID(authenticatedRemoteKey)
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteKey)) {
authenticatedRemoteKey.fill(0)
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
remoteStaticPublicKey = authenticatedRemoteKey
Log.d(TAG, "Remote static public key is bound to $peerID")
// Only a bound remote identity may derive transport ciphers.
val cipherPair = activeHandshake.split()
sendCipher = cipherPair.getSender()
receiveCipher = cipherPair.getReceiver()
// Extract handshake hash for channel binding
handshakeHash = handshakeState?.getHandshakeHash()
handshakeHash = activeHandshake.getHandshakeHash()
// Clean up handshake state
handshakeState?.destroy()
activeHandshake.destroy()
handshakeState = null
messagesSent = 0
@@ -3,6 +3,13 @@ package com.bitchat.android.noise
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
data class NoiseHandshakeProcessingResult(
val response: ByteArray?,
val establishedNow: Boolean,
/** The bound remote static key only when this exact call completed authentication. */
val authenticatedRemoteStaticKey: ByteArray? = null
)
/**
* SIMPLIFIED Noise session manager - focuses on core functionality only
*/
@@ -19,6 +26,9 @@ class NoiseSessionManager(
}
private val sessions = ConcurrentHashMap<String, NoiseSession>()
// An inbound replacement handshake must prove its authenticated static-key binding before it
// can evict a working transport session. Keep responder candidates outside the active map.
private val responderCandidates = ConcurrentHashMap<String, NoiseSession>()
// Callbacks
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
@@ -29,8 +39,10 @@ class NoiseSessionManager(
/**
* Add new session for a peer
*/
@Synchronized
fun addSession(peerID: String, session: NoiseSession) {
sessions[peerID] = session
val previous = sessions.put(peerID, session)
if (previous != null && previous !== session) previous.destroy()
Log.d(TAG, "Added new session for $peerID")
}
@@ -45,15 +57,17 @@ class NoiseSessionManager(
/**
* Remove session for a peer
*/
@Synchronized
fun removeSession(peerID: String) {
sessions[peerID]?.destroy()
sessions.remove(peerID)
sessions.remove(peerID)?.destroy()
responderCandidates.remove(peerID)?.destroy()
Log.d(TAG, "Removed session for $peerID")
}
/**
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/
@Synchronized
fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)")
@@ -94,7 +108,7 @@ class NoiseSessionManager(
Log.d(TAG, "Started handshake with $peerID as INITIATOR")
return handshakeData
} catch (e: Exception) {
sessions.remove(peerID)
if (sessions.remove(peerID, session)) session.destroy()
throw e
}
}
@@ -103,62 +117,132 @@ class NoiseSessionManager(
* Handle incoming handshake message
*/
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
try {
var session = getSession(peerID)
return processHandshakeMessageWithResult(peerID, message).response
}
// Collision handling: both sides initiated and we received message 1
if (session != null &&
session.isHandshaking() &&
session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE
) {
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
removeSession(peerID)
session = null
@Synchronized
fun processHandshakeMessageWithResult(
peerID: String,
message: ByteArray
): NoiseHandshakeProcessingResult {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
var activeSession: NoiseSession? = null
var isReplacementCandidate = false
var establishedRemoteKey: ByteArray? = null
var response: ByteArray? = null
try {
val existingCandidate = responderCandidates[peerID]
if (existingCandidate != null) {
activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) {
responderCandidates.remove(peerID, existingCandidate)
existingCandidate.destroy()
createSession(peerID, isInitiator = false).also {
responderCandidates[peerID] = it
}
} else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
return null
existingCandidate
}
isReplacementCandidate = true
} else {
var session = getSession(peerID)
// Collision handling: both sides initiated and we received message 1.
if (session != null &&
session.isHandshaking() &&
session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE
) {
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
if (sessions.remove(peerID, session)) session.destroy()
session = null
} else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
return NoiseHandshakeProcessingResult(response = null, establishedNow = false)
}
}
activeSession = when {
session == null -> {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
}
session.isEstablished() -> {
Log.d(
TAG,
"Validating replacement handshake for $peerID while preserving active session"
)
isReplacementCandidate = true
createSession(peerID, isInitiator = false).also {
responderCandidates[peerID] = it
}
}
session.isHandshaking() &&
!session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE -> {
// A restarted responder handshake can replace an incomplete session because
// there is no working transport state to preserve.
if (sessions.remove(peerID, session)) session.destroy()
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
}
else -> session
}
}
// If no session exists, create one as responder
if (session == null) {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
session = NoiseSession(
peerID = peerID,
isInitiator = false,
localStaticPrivateKey = localStaticPrivateKey,
localStaticPublicKey = localStaticPublicKey
)
addSession(peerID, session)
}
// Process handshake message
val response = session.processHandshakeMessage(message)
// Check if session is established
val session = activeSession ?: throw NoiseSessionError.InvalidState
response = session.processHandshakeMessage(message)
if (session.isEstablished()) {
Log.d(TAG, "✅ Session ESTABLISHED with $peerID")
val remoteStaticKey = session.getRemoteStaticPublicKey()
if (remoteStaticKey != null) {
onSessionEstablished?.invoke(peerID, remoteStaticKey)
?: throw NoiseSessionError.HandshakeFailed
val derivedPeerID = NoisePeerIdentity.derivePeerID(remoteStaticKey)
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStaticKey)) {
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
val previous = sessions.put(peerID, session)
if (previous != null && previous !== session) previous.destroy()
}
establishedRemoteKey = remoteStaticKey
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
}
return response
} catch (e: Exception) {
val session = activeSession
if (session != null) {
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
} else {
sessions.remove(peerID, session)
}
session.destroy()
}
Log.e(TAG, "Handshake failed with $peerID: ${e.message}")
sessions.remove(peerID)
onSessionFailed?.invoke(peerID, e)
runCatching { onSessionFailed?.invoke(peerID, e) }
throw e
}
establishedRemoteKey?.let { onSessionEstablished?.invoke(peerID, it) }
return NoiseHandshakeProcessingResult(
response = response,
establishedNow = establishedRemoteKey != null,
authenticatedRemoteStaticKey = establishedRemoteKey?.clone()
)
}
private fun createSession(peerID: String, isInitiator: Boolean): NoiseSession = NoiseSession(
peerID = peerID,
isInitiator = isInitiator,
localStaticPrivateKey = localStaticPrivateKey,
localStaticPublicKey = localStaticPublicKey
)
private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
if (lastActivity == null) return false
@@ -239,6 +323,7 @@ class NoiseSessionManager(
fun getDebugInfo(): String = buildString {
appendLine("=== Noise Session Manager Debug ===")
appendLine("Active sessions: ${sessions.size}")
appendLine("Responder candidates: ${responderCandidates.size}")
appendLine("")
if (sessions.isNotEmpty()) {
@@ -252,9 +337,12 @@ class NoiseSessionManager(
/**
* Shutdown manager and clean up all sessions
*/
@Synchronized
fun shutdown() {
sessions.values.forEach { it.destroy() }
responderCandidates.values.forEach { it.destroy() }
sessions.clear()
responderCandidates.clear()
Log.d(TAG, "Noise session manager shut down")
}
}
@@ -268,4 +356,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
object InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed")
object AlreadyEstablished : NoiseSessionError("Session already established")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
"Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID"
)
}
@@ -0,0 +1,25 @@
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 Link<T : Any>(
val relayAddress: String,
val transport: T
)
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 }
}
}
@@ -23,6 +23,7 @@ class WifiAwareConnectionTracker(
// Active resources per peer
val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
private val socketAliases = ConcurrentHashMap<String, String>()
private val socketBindingLock = Any()
val serverSockets = ConcurrentHashMap<String, ServerSocket>()
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
@@ -32,24 +33,26 @@ class WifiAwareConnectionTracker(
}
override fun disconnect(id: String) {
Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 1. Close client socket
peerSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
}
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
synchronized(socketBindingLock) {
Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 2. Close server socket
serverSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
}
// 1. Close client socket
peerSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
}
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
// Ensure any pending/active network request is explicitly released
releaseNetworkRequest(canonicalId)
removePendingConnection(id)
removePendingConnection(canonicalId)
// 2. Close server socket
serverSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
}
// Ensure any pending/active network request is explicitly released
releaseNetworkRequest(canonicalId)
removePendingConnection(id)
removePendingConnection(canonicalId)
}
}
fun releaseNetworkRequest(id: String) {
@@ -71,12 +74,16 @@ class WifiAwareConnectionTracker(
* Successfully established a client connection
*/
fun onClientConnected(peerId: String, socket: SyncedSocket) {
// Close previous socket if one exists to prevent zombie readers
peerSockets[peerId]?.let {
try { it.close() } catch (_: Exception) {}
synchronized(socketBindingLock) {
val canonicalPeerId = resolveCanonicalPeerId(peerId)
// Close previous socket if one exists to prevent zombie readers
peerSockets[canonicalPeerId]?.let {
try { it.close() } catch (_: Exception) {}
}
peerSockets[canonicalPeerId] = socket
removePendingConnection(peerId) // Clear retry state on success
if (canonicalPeerId != peerId) removePendingConnection(canonicalPeerId)
}
peerSockets[peerId] = socket
removePendingConnection(peerId) // Clear retry state on success
}
fun getSocketForPeer(peerId: String): SyncedSocket? {
@@ -87,6 +94,37 @@ class WifiAwareConnectionTracker(
fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId)
fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String {
return synchronized(socketBindingLock) {
rebindPeerIdLocked(previousPeerId, resolvedPeerId, socket)
}
}
/**
* 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.
*/
fun rebindPeerIdIfCurrent(
previousPeerId: String,
resolvedPeerId: String,
expectedSocket: SyncedSocket
): Boolean = synchronized(socketBindingLock) {
val previousCanonical = resolveCanonicalPeerId(previousPeerId)
if (peerSockets[previousCanonical] !== expectedSocket) return@synchronized false
val resolvedCanonical = resolveCanonicalPeerId(resolvedPeerId)
val existingResolvedSocket = peerSockets[resolvedCanonical]
if (existingResolvedSocket != null && existingResolvedSocket !== expectedSocket) {
return@synchronized false
}
rebindPeerIdLocked(previousPeerId, resolvedPeerId, expectedSocket)
true
}
private fun rebindPeerIdLocked(
previousPeerId: String,
resolvedPeerId: String,
socket: SyncedSocket
): String {
if (previousPeerId == resolvedPeerId) {
peerSockets[resolvedPeerId] = socket
return resolvedPeerId
@@ -42,6 +42,7 @@ import java.net.ServerSocket
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@@ -121,6 +122,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Transport state
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
>()
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
@@ -167,8 +172,20 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
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. The resulting packet broadcasts when no canonical
// alias exists; only a same-link Noise completion may promote that alias.
val relay = routed.relayAddress
if (routed.packet.ttl == MAX_TTL && relay != null && relay != pid) {
meshCore.initiateNoiseHandshake(pid)
}
}
},
onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
},
announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
},
@@ -1241,6 +1258,66 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
else -> myPeerID < peerId
}
/**
* Promote a provisional discovery alias only when the exact, still-active socket delivered the
* Noise frame that completed authentication for the canonical peer ID.
*/
private fun promoteAuthenticatedIngressLink(
canonicalPeerId: String,
relayAddress: String,
ingressLinkID: String
) {
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: run {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
return
}
val provisionalPeerId = link.relayAddress
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
if (existingCanonical == canonicalPeerId) {
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
return
}
if (existingCanonical != provisionalPeerId) {
Log.w(
TAG,
"Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on an existing alias"
)
return
}
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
Log.w(
TAG,
"Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed before rebind"
)
return
}
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
}
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
discoveredTimestamps[canonicalPeerId] = 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) { }
Log.i(
TAG,
"Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)} on exact ingress link"
)
}
/**
* Listens for incoming packets from a connected peer and dispatches them through
* the packet processor.
@@ -1249,7 +1326,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
* @param initialLogicalPeerId Temporary identifier before peer ID resolution
*/
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
var logicalPeerId = initialLogicalPeerId
val logicalPeerId = initialLogicalPeerId
val ingressLinkID = UUID.randomUUID().toString()
val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
ingressLinks[ingressLinkID] = ingressLink
while (isActive) {
val raw = socket.read() ?: break
@@ -1263,29 +1343,23 @@ 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) {
val previousPeerId = logicalPeerId
logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == previousPeerId) {
handleToPeerId[handle] = senderPeerHex
}
}
subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it }
discoveredTimestamps.remove(previousPeerId)
discoveredTimestamps[senderPeerHex] = System.currentTimeMillis()
try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { }
try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { }
try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { }
publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it }
Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}")
// 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.w(
TAG,
"RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof"
)
}
// Route the packet:
// - peerID = Originator (who signed it)
// - relayAddress = Neighbor (who sent it to us over this socket)
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})")
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId)
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId, ingressLinkID)
}
ingressLinks.remove(ingressLinkID, ingressLink)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")