diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index 62df7556..3266f803 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt new file mode 100644 index 00000000..89b833b1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 3a4d3987..b799d759 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index ed865897..06e4cf5e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 2679e60a..492b01b9 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -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? diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index fb47f40f..dd775089 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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? diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 5d5984f7..dcff95d1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt b/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt index 24e7a9ce..a139d6df 100644 --- a/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt @@ -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? = null + val preparedPackets: List? = 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 ) diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 5ca8df28..896dd1d0 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt b/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt new file mode 100644 index 00000000..1a9b4da0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index 4eab42bb..4be17942 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index 618f26be..ff80de48 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -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() + // 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() // 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" + ) } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt new file mode 100644 index 00000000..353b0e9c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt @@ -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( + val relayAddress: String, + val transport: T + ) + + fun resolve( + authenticatedLinkID: String?, + authenticatedRelayAddress: String?, + links: Map>, + currentTransportForRelay: (String) -> T? + ): Link? { + 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 } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt index 115dbb05..9376b078 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -23,6 +23,7 @@ class WifiAwareConnectionTracker( // Active resources per peer val peerSockets = ConcurrentHashMap() private val socketAliases = ConcurrentHashMap() + private val socketBindingLock = Any() val serverSockets = ConcurrentHashMap() val networkCallbacks = ConcurrentHashMap() @@ -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 diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index ce1d4c2d..4b09a1ba 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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 + >() private val handleToPeerId = ConcurrentHashMap() // discovery mapping private val discoveredTimestamps = ConcurrentHashMap() // 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.") diff --git a/app/src/test/kotlin/android/util/Log.kt b/app/src/test/kotlin/android/util/Log.kt index 703cbcb8..1d0bd3f1 100644 --- a/app/src/test/kotlin/android/util/Log.kt +++ b/app/src/test/kotlin/android/util/Log.kt @@ -12,6 +12,11 @@ fun e(tag: String, msg: String): Int { return 0; } +fun e(tag: String, msg: String, throwable: Throwable): Int { + println("ERROR: $tag: $msg (${throwable.message})") + return 0; +} + fun w(tag: String, msg: String): Int { println("WARN: $tag: $msg") return 0; @@ -25,4 +30,4 @@ fun v(tag: String, msg: String): Int { fun i(tag: String, msg: String): Int { println("INFO: $tag: $msg") return 0; -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 065b2f1f..6bf60d64 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -5,6 +5,7 @@ import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoisePeerIdentity import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients @@ -20,7 +21,6 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq -import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -36,9 +36,9 @@ class MessageHandlerTest { private lateinit var delegate: MessageHandlerDelegate private val myPeerID = "1111222233334444" - private val peerID = "aaaabbbbccccdddd" - private val nickname = "peer" private val noiseKey = ByteArray(32) { 0x0B } + private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!! + private val nickname = "peer" private val signingKey = ByteArray(32) { 0x0A } private val signature = ByteArray(64) { 1 } private val announceClockSkewToleranceMs = 10 * 60 * 1000L @@ -73,10 +73,8 @@ class MessageHandlerTest { assertTrue("Announce within clock skew tolerance should still store peer identity", result) verify(delegate).updatePeerInfoFromVerifiedAnnouncement( - eq(peerID), eq(nickname), any(), any(), eq(true), isNull() + eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull() ) - verify(delegate).onVerifiedAnnouncementProcessed(peerID) - verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull()) } } @@ -89,7 +87,7 @@ class MessageHandlerTest { assertTrue("Future announce within clock skew tolerance should still store peer identity", result) verify(delegate).updatePeerInfoFromVerifiedAnnouncement( - eq(peerID), eq(nickname), any(), any(), eq(true), isNull() + eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull() ) } } @@ -127,8 +125,6 @@ class MessageHandlerTest { verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( any(), any(), any(), any(), any(), anyOrNull() ) - verify(delegate, never()).onVerifiedAnnouncementProcessed(any()) - verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any()) } } @@ -144,7 +140,6 @@ class MessageHandlerTest { verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( any(), any(), any(), any(), any(), anyOrNull() ) - verify(delegate, never()).onVerifiedAnnouncementProcessed(any()) } } @@ -244,14 +239,90 @@ class MessageHandlerTest { } } + @Test + fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking { + val attackerNoiseKey = ByteArray(32) { 0x6B } + val packet = announcePacket(ageMs = 0, noisePublicKey = attackerNoiseKey) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse("A valid self-signature cannot bind an attacker key to a victim ID", result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `announce requires a 32-byte Noise static key before peer update`() = runBlocking { + val malformedNoiseKey = ByteArray(31) { 0x0B } + val packet = announcePacket(ageMs = 0, noisePublicKey = malformedNoiseKey) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `announce packet sender cannot be processed under a different routed peer ID`() = runBlocking { + val otherPeerID = NoisePeerIdentity.derivePeerID(ByteArray(32) { 0x21 })!! + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, otherPeerID, "relay-link")) + + assertFalse(result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `known peer signing key cannot be replaced without bound Noise session`() = runBlocking { + whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 })) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `ambient bound Noise session does not authorize signing key replacement`() = runBlocking { + whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 })) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + private fun announcePacket( ageMs: Long, ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), - capabilities: PeerCapabilities? = null + capabilities: PeerCapabilities? = null, + noisePublicKey: ByteArray = noiseKey ): BitchatPacket { val announcement = IdentityAnnouncement( nickname = nickname, - noisePublicKey = noiseKey, + noisePublicKey = noisePublicKey, signingPublicKey = signingKey, capabilities = capabilities ) @@ -270,4 +341,15 @@ class MessageHandlerTest { private fun String.hexToBytes(): ByteArray { return chunked(2).map { it.toInt(16).toByte() }.toByteArray() } + + private fun peerInfo(signingPublicKey: ByteArray) = PeerInfo( + id = peerID, + nickname = nickname, + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = signingPublicKey, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt new file mode 100644 index 00000000..c5e42072 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -0,0 +1,104 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PacketProcessorAnnounceSideEffectTest { + private val processors = mutableListOf() + + @After + fun tearDown() { + processors.forEach(PacketProcessor::shutdown) + } + + @Test + fun `rejected announce does not update last seen or relay`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = false) + val processor = processor(delegate) + + processor.processPacket(announce()) + withTimeout(1_000) { delegate.handled.await() } + + assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() }) + assertEquals(0, delegate.relayCount) + } + + @Test + fun `accepted announce unlocks post-handler side effects`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(announce()) + + assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() }) + } + + private fun processor(delegate: RecordingDelegate): PacketProcessor = + PacketProcessor(MY_PEER_ID).also { + it.delegate = delegate + processors += it + } + + private fun announce(): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = PEER_ID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x01), + ttl = 7u + ) + return RoutedPacket(packet, PEER_ID, "direct-link") + } + + private class RecordingDelegate( + private val acceptAnnounce: Boolean + ) : PacketProcessorDelegate { + val handled = CompletableDeferred() + val lastSeen = CompletableDeferred() + @Volatile var relayCount = 0 + + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = true + override fun updatePeerLastSeen(peerID: String) { + lastSeen.complete(peerID) + } + override fun getPeerNickname(peerID: String): String? = null + override fun getNetworkSize() = 1 + override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST + override fun handleNoiseHandshake(routed: RoutedPacket) = false + override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + handled.complete(Unit) + return acceptAnnounce + } + override fun handleMessage(routed: RoutedPacket) = Unit + override fun handleLeave(routed: RoutedPacket) = Unit + override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null + override fun handleRequestSync(routed: RoutedPacket) = Unit + override fun sendAnnouncementToPeer(peerID: String) = Unit + override fun sendCachedMessages(peerID: String) = Unit + override fun relayPacket(routed: RoutedPacket) { + relayCount += 1 + } + override fun sendToPeer(peerID: String, routed: RoutedPacket) = false + } + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val MY_PEER_ID = "1111222233334444" + const val PEER_ID = "aaaabbbbccccdddd" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt index 04b10694..10033c30 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -3,8 +3,13 @@ package com.bitchat.android.mesh import android.os.Build import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoiseHandshakeProcessingResult +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.noise.NoiseSessionError import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -26,15 +31,14 @@ class SecurityManagerTest { private val myPeerID = "1111222233334444" private val otherPeerID = "aaaabbbbccccdddd" - private val unknownPeerID = "9999888877776666" + // Key pairs (using dummy bytes for mock verification) + private val otherSigningKey = ByteArray(32) { 0xA } + private val otherNoiseKey = ByteArray(32) { 0xB } + private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!! private val dummyPayload = "Hello World".toByteArray() private val validSignature = ByteArray(64) { 1 } private val invalidSignature = ByteArray(64) { 0 } - - // Key pairs (using dummy bytes for mock verification) - private val otherSigningKey = ByteArray(32) { 0xA } - private val otherNoiseKey = ByteArray(32) { 0xB } // Fake implementation to bypass initialization issues in tests open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) { @@ -42,6 +46,10 @@ class SecurityManagerTest { var lastVerifySignature: ByteArray? = null var lastVerifyData: ByteArray? = null var lastVerifyKey: ByteArray? = null + var handshakeResult = NoiseHandshakeProcessingResult(null, false) + var handshakeError: Exception? = null + var handshakeCalls = 0 + var removePeerCalls = 0 override fun initialize() { // Do nothing to avoid KeyStore access in tests @@ -59,6 +67,19 @@ class SecurityManagerTest { } return false } + + override fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + handshakeCalls += 1 + handshakeError?.let { throw it } + return handshakeResult + } + + override fun removePeer(peerID: String) { + removePeerCalls += 1 + } } @Before @@ -197,6 +218,41 @@ class SecurityManagerTest { assertTrue("Valid signed packet from known peer should be accepted", result) } + @Test + fun `validatePacket rejects unsigned and invalidly signed LEAVE packets`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val unsigned = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = byteArrayOf() + ) + assertFalse("Unsigned LEAVE must not evict or relay the claimed peer", securityManager.validatePacket(unsigned, otherPeerID)) + + val invalid = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = "forged".toByteArray() + ).also { it.signature = invalidSignature } + assertFalse("Bad LEAVE signature must be rejected", securityManager.validatePacket(invalid, otherPeerID)) + } + + @Test + fun `validatePacket accepts signed LEAVE from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = byteArrayOf() + ).also { it.signature = validSignature } + + assertTrue("A valid signed LEAVE remains wire-compatible", securityManager.validatePacket(packet, otherPeerID)) + assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + @Test fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { val announcement = IdentityAnnouncement( @@ -261,6 +317,33 @@ class SecurityManagerTest { assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) } + @Test + fun `validatePacket rejects self-signed announce whose Noise key derives another sender ID`() { + val attackerNoiseKey = ByteArray(32) { 0x6B } + val announcement = IdentityAnnouncement("Attacker", attackerNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, unknownPeerID)) + } + + @Test + fun `validatePacket rejects announce packet under a different routed sender`() { + val announcement = IdentityAnnouncement("Peer", otherNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, otherPeerID)) + } + @Test fun `validatePacket - ignores own packets`() { val packet = BitchatPacket( @@ -326,6 +409,86 @@ class SecurityManagerTest { assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) } + @Test + fun `replacement message one sends response without evicting or falsely completing`() = runBlocking { + val response = byteArrayOf(0x31, 0x32) + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(response, false) + val routed = handshakePacket(byteArrayOf(0x01, 0x02, 0x03)) + + val accepted = securityManager.handleNoiseHandshake(routed) + + assertTrue(accepted) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + verify(mockDelegate).sendHandshakeResponse(otherPeerID, response) + verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `identity mismatch preserves peer and does not poison retry or completion`() = runBlocking { + val routed = handshakePacket(byteArrayOf(0x41, 0x42, 0x43)) + fakeEncryptionService.handshakeError = NoiseSessionError.PeerIdentityMismatch( + otherPeerID, + "0000000000000000" + ) + + assertFalse(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) + verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull()) + + fakeEncryptionService.handshakeError = null + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey + ) + assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + verify(mockDelegate).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + "direct-link", + "direct-link-token" + ) + } + + @Test + fun `completion callback fires only for the frame that establishes a bound session`() = runBlocking { + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey + ) + val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53)) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + + verify(mockDelegate, times(1)).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + "direct-link", + "direct-link-token" + ) + verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + } + + @Test + fun `relayed completion does not authenticate the relay as the peer link`() = runBlocking { + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey + ) + val routed = handshakePacket( + payload = byteArrayOf(0x61, 0x62, 0x63), + ttl = 6u + ) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + verify(mockDelegate).onKeyExchangeCompleted(otherPeerID, otherNoiseKey, null, null) + } + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { val info = PeerInfo( id = peerID, @@ -339,4 +502,25 @@ class SecurityManagerTest { ) whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) } + + private fun handshakePacket(payload: ByteArray, ttl: UByte = 7u): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = otherPeerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = ttl + ) + return RoutedPacket( + packet = packet, + peerID = otherPeerID, + relayAddress = "direct-link", + ingressLinkID = "direct-link-token" + ) + } + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() } diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt new file mode 100644 index 00000000..431be1fc --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt @@ -0,0 +1,217 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class NoiseSessionManagerIdentityBindingTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `valid derived identities establish in initiator and responder roles`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID)) + + val plaintext = "bound transport".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `initiator rejects remote static key before returning message three`() { + val alice = identity() + val bob = identity() + val victim = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + var authenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 } + + val message1 = aliceManager.initiateHandshake(victim.peerID)!! + val message2 = bobManager.processHandshakeMessage(alice.peerID, message1)!! + + expectIdentityMismatch { + aliceManager.processHandshakeMessage(victim.peerID, message2) + } + + assertFalse(aliceManager.hasEstablishedSession(victim.peerID)) + assertNull(aliceManager.getSession(victim.peerID)) + assertTrue("No authenticated callback may escape a mismatched initiator", authenticatedCallbacks == 0) + } + + @Test + fun `responder rejects authenticated initiator key under a different claimed ID`() { + val alice = identity() + val bob = identity() + val victim = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + var authenticatedCallbacks = 0 + bobManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 } + + val message1 = aliceManager.initiateHandshake(bob.peerID)!! + val message2 = bobManager.processHandshakeMessage(victim.peerID, message1)!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + + expectIdentityMismatch { + bobManager.processHandshakeMessage(victim.peerID, message3) + } + + assertFalse(bobManager.hasEstablishedSession(victim.peerID)) + assertNull(bobManager.getSession(victim.peerID)) + assertTrue("No authenticated callback may escape a mismatched responder", authenticatedCallbacks == 0) + } + + @Test + fun `mismatched responder replacement preserves established session and transport keys`() { + val alice = identity() + val bob = identity() + val attacker = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + val attackerManager = manager(attacker) + var aliceAuthenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 } + + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + assertTrue(aliceAuthenticatedCallbacks == 1) + + val attackerMessage1 = attackerManager.initiateHandshake(alice.peerID)!! + val aliceMessage2 = aliceManager.processHandshakeMessage(bob.peerID, attackerMessage1)!! + val attackerMessage3 = attackerManager.processHandshakeMessage(alice.peerID, aliceMessage2)!! + + expectIdentityMismatch { + aliceManager.processHandshakeMessage(bob.peerID, attackerMessage3) + } + + assertSame("Rejected candidate must not replace the working session", originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertTrue("Rejected replacement must not fire authentication callback", aliceAuthenticatedCallbacks == 1) + + val plaintext = "original session survives".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + + // The failed candidate must be fully removed so a later valid restart can replace cleanly. + val restartedBobManager = manager(bob) + val validMessage1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val validMessage2 = aliceManager.processHandshakeMessage(bob.peerID, validMessage1)!! + val validMessage3 = restartedBobManager.processHandshakeMessage(alice.peerID, validMessage2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, validMessage3)) + assertTrue(aliceAuthenticatedCallbacks == 2) + + val retriedPlaintext = "valid retry promoted".toByteArray() + val retriedCiphertext = restartedBobManager.encrypt(retriedPlaintext, alice.peerID) + assertArrayEquals(retriedPlaintext, aliceManager.decrypt(retriedCiphertext, bob.peerID)) + } + + @Test + fun `valid responder replacement promotes only after bound handshake completes`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + var aliceAuthenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 } + + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + + // Simulate Bob restarting with the same persistent static identity and no session state. + val restartedBobManager = manager(bob) + val message1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!! + val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3)) + + assertNotSame(originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertTrue(aliceAuthenticatedCallbacks == 2) + + val plaintext = "replacement transport".toByteArray() + val ciphertext = restartedBobManager.encrypt(plaintext, alice.peerID) + assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID)) + } + + @Test + fun `peer ID derivation rejects malformed keys and non-wire claims`() { + val peer = identity() + + assertTrue(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID.uppercase(), peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID("not-a-wire-id", peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, ByteArray(31))) + assertNull(NoisePeerIdentity.derivePeerID(ByteArray(31))) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun expectIdentityMismatch(block: () -> Unit) { + try { + block() + fail("Expected authenticated Noise key to be rejected for the claimed peer ID") + } catch (_: NoiseSessionError.PeerIdentityMismatch) { + // Expected. + } + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt new file mode 100644 index 00000000..9480ea69 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.wifiaware + +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class AuthenticatedIngressLinkPolicyTest { + @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 } + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt new file mode 100644 index 00000000..b1b12d0e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.wifiaware + +import android.net.ConnectivityManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.Socket + +class WifiAwareConnectionTrackerTest { + @Test + fun `compare and rebind rejects stale authenticated socket after replacement`() { + val tracker = WifiAwareConnectionTracker( + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mock() + ) + val authenticatedSocket = syncedSocket() + val replacementSocket = syncedSocket() + tracker.onClientConnected("provisional", authenticatedSocket) + tracker.onClientConnected("provisional", replacementSocket) + + assertFalse( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = authenticatedSocket + ) + ) + assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) + assertNull(tracker.getSocketForPeer("canonical")) + + assertTrue( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = replacementSocket + ) + ) + assertSame(replacementSocket, tracker.getSocketForPeer("canonical")) + assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) + } + + @Test + fun `authenticated provisional socket cannot displace existing canonical socket`() { + val tracker = WifiAwareConnectionTracker( + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mock() + ) + val provisionalSocket = syncedSocket() + val canonicalSocket = syncedSocket() + tracker.onClientConnected("provisional", provisionalSocket) + tracker.onClientConnected("canonical", canonicalSocket) + + assertFalse( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = provisionalSocket + ) + ) + assertSame(provisionalSocket, tracker.getSocketForPeer("provisional")) + assertSame(canonicalSocket, tracker.getSocketForPeer("canonical")) + assertTrue("Rejected promotion must not alias the provisional ID", tracker.canonicalPeerId("provisional") == "provisional") + } + + private fun syncedSocket(): SyncedSocket { + val raw = mock { + on { getInputStream() } doReturn ByteArrayInputStream(byteArrayOf()) + on { getOutputStream() } doReturn ByteArrayOutputStream() + } + return SyncedSocket(raw) + } +} diff --git a/docs/NOISE_PEER_ID_BINDING.md b/docs/NOISE_PEER_ID_BINDING.md new file mode 100644 index 00000000..1b2e319c --- /dev/null +++ b/docs/NOISE_PEER_ID_BINDING.md @@ -0,0 +1,52 @@ +# Noise peer-ID binding + +Mesh wire peer IDs are exactly 16 lowercase hexadecimal characters derived as +`hex(SHA-256(noiseStaticPublicKey)[0..<8])`. Android enforces that binding at +both identity entry points: + +- A verified announcement must carry a 32-byte Noise static key whose derived + ID matches both the packet sender and routed sender. +- A Noise XX initiator or responder must authenticate a remote static key whose + derived ID matches the claimed session key before transport ciphers are + exposed or an authentication callback runs. + +Inbound rehandshakes use a separate responder candidate. An established +session remains active until the candidate completes and passes the binding; +failure or mismatch destroys only the candidate. BLE mappings, Wi-Fi socket +rebinds, gossip, sync, and peer-last-seen effects run only after announcement +validation succeeds. A Wi-Fi discovery identity is not destructively rebound +from a self-signed announce. A direct announce may start the canonical +handshake, but the alias is promoted only when the exact, still-active socket +delivers the Noise frame that completes bound authentication. A peer-ID-only +or stale-socket callback cannot authorize that rebind; the final +expected-socket comparison and alias mutation are atomic with socket replacement. +Promotion also refuses to displace a different live socket already authenticated +under the canonical peer ID. + +Leave packets use the existing signed wire format and are accepted only when +the signature matches the key learned from a verified announcement. Invalid or +unsigned leaves therefore cannot evict the claimed peer or be relayed. A valid +leave removes the peer through the normal peer-manager path, which also clears +its active Noise session. + +Announcements no longer write fingerprint mappings. Those mappings are created +only by the authenticated Noise-session callback. A known peer's signing key +also cannot change based on an announcement or merely because some session for +that peer ID is active. Rotation requires an authenticated peer-state proof +tied to the exact Noise channel; ambient session presence is not enough. +The same authenticated callback restores any existing Noise-key-to-Nostr +relationship under the canonical 16-hex mesh ID; unproven announcements never +write that routing index. + +## Remaining TOFU boundary + +The first public announcement is still self-signed trust-on-first-use. An +attacker can copy a public Noise key and self-sign an announcement, but cannot +complete the bound Noise handshake for that ID. Public-mesh identity admission +is intentionally not gated behind an automatic handshake in this change; doing +so is a separate availability/protocol decision. + +Consequently, discovery metadata or capability bits in an announcement are +hints, not proof of Noise-key possession. Security-sensitive capabilities must +be confirmed inside the authenticated Noise channel before they are pinned or +used to authorize a downgrade-sensitive behavior.