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

# Conflicts:
#	app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
#	app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
#	app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
#	app/src/main/java/com/bitchat/android/model/RoutedPacket.kt
#	app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt
This commit is contained in:
jack
2026-07-12 11:18:17 -04:00
23 changed files with 1416 additions and 277 deletions
@@ -7,6 +7,7 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@@ -291,10 +292,23 @@ open class EncryptionService(private val context: Context) {
return noiseService.processHandshakeMessage(data, 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) * Remove a peer session (called when peer disconnects)
*/ */
fun removePeer(peerID: String) { open fun removePeer(peerID: String) {
establishedSessions.remove(peerID) establishedSessions.remove(peerID)
noiseService.removePeer(peerID) noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID) onSessionLost?.invoke(peerID)
@@ -0,0 +1,37 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */
object AnnouncementIdentityValidator {
private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L
fun verify(
packet: BitchatPacket,
claimedPeerID: String,
nowMs: Long = System.currentTimeMillis(),
verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean
): IdentityAnnouncement? {
if (packet.type != MessageType.ANNOUNCE.value) return null
val now = nowMs.coerceAtLeast(0).toULong()
val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp
if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null
val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null
if (announcement.signingPublicKey.size != 32) return null
val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null
if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null
val signature = packet.signature ?: return null
val canonicalData = packet.toBinaryDataForSigning() ?: return null
return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) {
announcement
} else {
null
}
}
}
@@ -234,8 +234,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// SecurityManager delegate for key exchange notifications // SecurityManager delegate for key exchange notifications
securityManager.delegate = object : SecurityManagerDelegate { securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { override fun onKeyExchangeCompleted(
privateMediaSecurity.refreshAuthenticatedCapability(peerID) peerID: String,
authenticatedRemoteStaticKey: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
// Send announcement and cached messages after key exchange // Send announcement and cached messages after key exchange
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") 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 // Message operations
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel) return delegate?.decryptChannelMessage(encryptedContent, channel)
@@ -521,35 +500,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
} }
override fun handleAnnounce(routed: RoutedPacket) { override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
serviceScope.launch { val result = messageHandler.handleAnnounceWithResult(routed)
// Process the announce if (result !is AnnounceHandlingResult.Accepted) return false
val isFirst = messageHandler.handleAnnounce(routed)
// Map device address -> peerID based on TTL (max TTL = direct neighbor) // Map device address -> peerID only after identity binding, signature, freshness,
// Matches iOS logic: any announce with max TTL on a link defines the direct peer // and peer replacement policy have all accepted the announce.
val deviceAddress = routed.relayAddress val deviceAddress = routed.relayAddress
val pid = routed.peerID val pid = routed.peerID
if (deviceAddress != null && pid != null) { if (deviceAddress != null && pid != null) {
// Check if this is a direct connection (MAX TTL) val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
// Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS if (isDirect) {
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
if (isDirect) { try { peerManager.refreshPeerList() } catch (_: Exception) { }
// Bind or rebind this device address to the announcing peer try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
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) { }
}
} }
// Track for sync
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
} }
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
return true
} }
override fun handleMessage(routed: RoutedPacket) { override fun handleMessage(routed: RoutedPacket) {
@@ -41,8 +41,8 @@ class MeshCore(
) { ) {
data class Hooks( data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null, val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null, val onReadReceiptSent: ((String) -> Unit)? = null,
val announcementNicknameProvider: (() -> String?)? = null, val announcementNicknameProvider: (() -> String?)? = null,
@@ -134,8 +134,20 @@ class MeshCore(
packetProcessor.shutdown() packetProcessor.shutdown()
} }
fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) { fun processIncoming(
packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress)) packet: BitchatPacket,
peerID: String?,
relayAddress: String?,
ingressLinkID: String? = null
) {
packetProcessor.processPacket(
RoutedPacket(
packet = packet,
peerID = peerID,
relayAddress = relayAddress,
ingressLinkID = ingressLinkID
)
)
} }
fun sendFromBridge(packet: RoutedPacket) { fun sendFromBridge(packet: RoutedPacket) {
@@ -174,8 +186,20 @@ class MeshCore(
} }
securityManager.delegate = object : SecurityManagerDelegate { securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { override fun onKeyExchangeCompleted(
privateMediaSecurity.refreshAuthenticatedCapability(peerID) peerID: String,
authenticatedRemoteStaticKey: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke(
peerID,
directRelayAddress,
ingressLinkID,
authenticatedRemoteStaticKey
)
}
scope.launch { scope.launch {
delay(100) delay(100)
sendAnnouncementToPeer(peerID) 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? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel) return delegate?.decryptChannelMessage(encryptedContent, channel)
} }
@@ -382,12 +393,12 @@ class MeshCore(
scope.launch { messageHandler.handleNoiseEncrypted(routed) } scope.launch { messageHandler.handleNoiseEncrypted(routed) }
} }
override fun handleAnnounce(routed: RoutedPacket) { override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
scope.launch { val result = messageHandler.handleAnnounceWithResult(routed)
val isFirst = messageHandler.handleAnnounce(routed) if (result !is AnnounceHandlingResult.Accepted) return false
hooks.onAnnounceProcessed?.invoke(routed, isFirst) hooks.onAnnounceProcessed?.invoke(routed, result.isFirst)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
} return true
} }
override fun handleMessage(routed: RoutedPacket) { override fun handleMessage(routed: RoutedPacket) {
@@ -3,7 +3,6 @@ package com.bitchat.android.mesh
import android.util.Log import android.util.Log
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
@@ -13,6 +12,11 @@ import kotlinx.coroutines.*
import java.util.* import java.util.*
import kotlin.random.Random import kotlin.random.Random
sealed class AnnounceHandlingResult {
data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult()
object Rejected : AnnounceHandlingResult()
}
/** /**
* Handles processing of different message types * Handles processing of different message types
* Extracted from BluetoothMeshService for better separation of concerns * 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 * Handle announce message with TLV decoding and signature verification - exactly like iOS
*/ */
suspend fun handleAnnounce(routed: RoutedPacket): Boolean { 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 packet = routed.packet
val peerID = routed.peerID ?: "unknown" 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 // Peers use wall-clock packet timestamps; tolerate moderate device clock skew
// during identity learning, or later signed messages cannot be verified. // during identity learning, or later signed messages cannot be verified.
@@ -227,27 +235,20 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { 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)") 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) { } 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)") 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 = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
val announcement = IdentityAnnouncement.decode(packet.payload) delegate?.verifyEd25519Signature(signature, data, key) ?: false
}
if (announcement == null) { if (announcement == null) {
Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format") Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}")
return false return AnnounceHandlingResult.Rejected
} }
// Verify packet signature using the announced signing public key var verified = true
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)}")
}
}
// Check for existing peer with different noise public key // Check for existing peer with different noise public key
// If existing peer has a different noise public key, do not consider this verified // If existing peer has a different noise public key, do not consider this verified
@@ -258,10 +259,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
verified = false 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) // Require verified announce; ignore otherwise (no backward compatibility)
if (!verified) { if (!verified) {
Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...") Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...")
return false return AnnounceHandlingResult.Rejected
} }
// Successfully decoded TLV format exactly like iOS // Successfully decoded TLV format exactly like iOS
@@ -284,19 +296,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
capabilities = announcement.capabilities capabilities = announcement.capabilities
) ?: false ) ?: 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) // Update mesh graph from gossip neighbors (only if TLV present)
try { try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) 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) { } } catch (_: Exception) { }
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") 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 hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String) fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?)
// Message operations // Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
@@ -143,7 +143,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle public packet types (no address check needed) // Handle public packet types (no address check needed)
when (messageType) { when (messageType) {
MessageType.ANNOUNCE -> handleAnnounce(routed) MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed) MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.LEAVE -> handleLeave(routed) MessageType.LEAVE -> handleLeave(routed)
@@ -197,10 +197,10 @@ class PacketProcessor(private val myPeerID: String) {
/** /**
* Handle announce message * Handle announce message
*/ */
private suspend fun handleAnnounce(routed: RoutedPacket) { private suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown" val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}") 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) val reassembledPacket = delegate?.handleFragment(routed.packet)
if (reassembledPacket != null) { if (reassembledPacket != null) {
Log.d(TAG, "Fragment reassembled, processing complete message") 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 // Fragment relay is now handled by centralized PacketRelayManager
@@ -314,7 +321,7 @@ interface PacketProcessorDelegate {
// Message type handlers // Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket) fun handleNoiseEncrypted(routed: RoutedPacket)
fun handleAnnounce(routed: RoutedPacket) suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket) fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket? fun handleFragment(packet: BitchatPacket): BitchatPacket?
@@ -104,19 +104,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Skip our own handshake messages // Skip our own handshake messages
if (peerID == myPeerID) return false 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()) { if (packet.payload.isEmpty()) {
Log.w(TAG, "Noise handshake packet has empty payload") Log.w(TAG, "Noise handshake packet has empty payload")
return false return false
@@ -125,26 +112,38 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Prevent duplicate handshake processing // Prevent duplicate handshake processing
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}" 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") Log.d(TAG, "Already processed handshake: $exchangeKey")
return false return false
} }
Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)") Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
processedKeyExchanges.add(exchangeKey)
try { try {
// Process the Noise handshake through the updated EncryptionService // The session manager preserves an existing transport in a separate responder-candidate
val response = encryptionService.processHandshakeMessage(packet.payload, peerID) // 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") Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response")
// Send handshake response through delegate // Send handshake response through delegate
delegate?.sendHandshakeResponse(peerID, response) delegate?.sendHandshakeResponse(peerID, result.response)
} }
// Check if session is now established (handshake complete) if (result.establishedNow) {
if (encryptionService.hasEstablishedSession(peerID)) { 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") 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 return true
@@ -231,14 +230,44 @@ class SecurityManager(private val encryptionService: EncryptionService, private
*/ */
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean { private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try { 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( if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE, MessageType.ANNOUNCE,
MessageType.MESSAGE, MessageType.MESSAGE,
MessageType.FILE_TRANSFER MessageType.FILE_TRANSFER,
MessageType.LEAVE
)) { )) {
return true 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 // 1. Mandatory Signature Check
if (packet.signature == null) { if (packet.signature == null) {
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") 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 // 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
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
}
if (signingPublicKey == null) { if (signingPublicKey == null) {
// If we don't have a key (and it's not an announce), we can't verify. // 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 * Delegate interface for security manager callbacks
*/ */
interface SecurityManagerDelegate { 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 sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification
} }
@@ -12,5 +12,8 @@ data class RoutedPacket(
val relayAddress: String? = null, // Address it came from (for avoiding loopback) val relayAddress: String? = null, // Address it came from (for avoiding loopback)
val transferId: String? = null, // Optional stable transfer ID for progress tracking val transferId: String? = null, // Optional stable transfer ID for progress tracking
/** Exact fragments admitted during private-media prepare; never rebuild them at commit. */ /** Exact fragments admitted during private-media prepare; never rebuild them at commit. */
val preparedPackets: List<BitchatPacket>? = null val preparedPackets: List<BitchatPacket>? = null,
// Opaque, process-local ingress identity. Unlike relayAddress, this distinguishes replacement
// sockets for the same provisional peer and must never be serialized onto the mesh.
val ingressLinkID: String? = null
) )
@@ -194,13 +194,26 @@ class NoiseEncryptionService(private val context: Context) {
*/ */
fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? { fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? {
return try { return try {
sessionManager.processHandshakeMessage(peerID, data) processHandshakeMessageWithResult(data, peerID).response
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}") Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}")
null 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 * Check if we have an established session with a peer
*/ */
@@ -383,6 +396,20 @@ class NoiseEncryptionService(private val context: Context) {
// This is the ONLY place where fingerprints are stored - after successful Noise handshake // This is the ONLY place where fingerprints are stored - after successful Noise handshake
fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey) 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 // Calculate fingerprint for logging and callback
val fingerprint = calculateFingerprint(remoteStaticKey) val fingerprint = calculateFingerprint(remoteStaticKey)
@@ -0,0 +1,30 @@
package com.bitchat.android.noise
import java.security.MessageDigest
/**
* Canonical binding between an authenticated Noise static key and its mesh wire identity.
*
* Mesh peer IDs are the first eight bytes of SHA-256(staticPublicKey), encoded as 16 lowercase
* hexadecimal characters. A self-signed announcement or completed Noise XX handshake is not
* sufficient on its own: the claimed wire ID must also match this derivation.
*/
object NoisePeerIdentity {
const val STATIC_PUBLIC_KEY_SIZE = 32
const val WIRE_PEER_ID_LENGTH = 16
private val wirePeerIDPattern = Regex("^[0-9a-f]{$WIRE_PEER_ID_LENGTH}$")
fun derivePeerID(staticPublicKey: ByteArray): String? {
if (staticPublicKey.size != STATIC_PUBLIC_KEY_SIZE) return null
return MessageDigest.getInstance("SHA-256")
.digest(staticPublicKey)
.take(8)
.joinToString("") { "%02x".format(it) }
}
fun matchesClaimedPeerID(claimedPeerID: String, staticPublicKey: ByteArray): Boolean {
if (!wirePeerIDPattern.matches(claimedPeerID)) return false
return derivePeerID(staticPublicKey) == claimedPeerID
}
}
@@ -451,27 +451,33 @@ class NoiseSession(
Log.d(TAG, "Completing XX handshake with $peerID") Log.d(TAG, "Completing XX handshake with $peerID")
try { try {
// Split handshake state into transport ciphers val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed
val cipherPair = handshakeState?.split()
sendCipher = cipherPair?.getSender() // Authenticate the remote static key's claimed mesh identity before split creates
receiveCipher = cipherPair?.getReceiver() // transport ciphers or the session can become observable as Established.
if (!activeHandshake.hasRemotePublicKey()) throw NoiseSessionError.HandshakeFailed
// Extract remote static key if available val remoteDH = activeHandshake.getRemotePublicKey()
if (handshakeState?.hasRemotePublicKey() == true) { ?: throw NoiseSessionError.HandshakeFailed
val remoteDH = handshakeState?.getRemotePublicKey() val authenticatedRemoteKey = ByteArray(NoisePeerIdentity.STATIC_PUBLIC_KEY_SIZE)
if (remoteDH != null) { remoteDH.getPublicKey(authenticatedRemoteKey, 0)
remoteStaticPublicKey = ByteArray(32) val derivedPeerID = NoisePeerIdentity.derivePeerID(authenticatedRemoteKey)
remoteDH.getPublicKey(remoteStaticPublicKey!!, 0) if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteKey)) {
Log.d(TAG, "Remote static public key: ${remoteStaticPublicKey!!.joinToString("") { "%02x".format(it) }}") 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 // Extract handshake hash for channel binding
handshakeHash = handshakeState?.getHandshakeHash() handshakeHash = activeHandshake.getHandshakeHash()
// Clean up handshake state // Clean up handshake state
handshakeState?.destroy() activeHandshake.destroy()
handshakeState = null handshakeState = null
messagesSent = 0 messagesSent = 0
@@ -3,6 +3,13 @@ package com.bitchat.android.noise
import android.util.Log import android.util.Log
import java.util.concurrent.ConcurrentHashMap 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 * SIMPLIFIED Noise session manager - focuses on core functionality only
*/ */
@@ -19,6 +26,9 @@ class NoiseSessionManager(
} }
private val sessions = ConcurrentHashMap<String, NoiseSession>() private val sessions = ConcurrentHashMap<String, NoiseSession>()
// An inbound replacement handshake must prove its authenticated static-key binding before it
// can evict a working transport session. Keep responder candidates outside the active map.
private val responderCandidates = ConcurrentHashMap<String, NoiseSession>()
// Callbacks // Callbacks
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
@@ -29,8 +39,10 @@ class NoiseSessionManager(
/** /**
* Add new session for a peer * Add new session for a peer
*/ */
@Synchronized
fun addSession(peerID: String, session: NoiseSession) { 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") Log.d(TAG, "Added new session for $peerID")
} }
@@ -45,15 +57,17 @@ class NoiseSessionManager(
/** /**
* Remove session for a peer * Remove session for a peer
*/ */
@Synchronized
fun removeSession(peerID: String) { fun removeSession(peerID: String) {
sessions[peerID]?.destroy() sessions.remove(peerID)?.destroy()
sessions.remove(peerID) responderCandidates.remove(peerID)?.destroy()
Log.d(TAG, "Removed session for $peerID") Log.d(TAG, "Removed session for $peerID")
} }
/** /**
* SIMPLIFIED: Initiate handshake - no tie breaker, just start * SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/ */
@Synchronized
fun initiateHandshake(peerID: String): ByteArray? { fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)") Log.d(TAG, "initiateHandshake($peerID)")
@@ -94,7 +108,7 @@ class NoiseSessionManager(
Log.d(TAG, "Started handshake with $peerID as INITIATOR") Log.d(TAG, "Started handshake with $peerID as INITIATOR")
return handshakeData return handshakeData
} catch (e: Exception) { } catch (e: Exception) {
sessions.remove(peerID) if (sessions.remove(peerID, session)) session.destroy()
throw e throw e
} }
} }
@@ -103,62 +117,132 @@ class NoiseSessionManager(
* Handle incoming handshake message * Handle incoming handshake message
*/ */
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? { fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
return processHandshakeMessageWithResult(peerID, message).response
}
@Synchronized
fun processHandshakeMessageWithResult(
peerID: String,
message: ByteArray
): NoiseHandshakeProcessingResult {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)") Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
var activeSession: NoiseSession? = null
var isReplacementCandidate = false
var establishedRemoteKey: ByteArray? = null
var response: ByteArray? = null
try { try {
var session = getSession(peerID) val existingCandidate = responderCandidates[peerID]
if (existingCandidate != null) {
// Collision handling: both sides initiated and we received message 1 activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) {
if (session != null && responderCandidates.remove(peerID, existingCandidate)
session.isHandshaking() && existingCandidate.destroy()
session.isInitiatorRole() && createSession(peerID, isInitiator = false).also {
message.size == HANDSHAKE_MESSAGE_1_SIZE responderCandidates[peerID] = it
) { }
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
removeSession(peerID)
session = null
} else { } else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role") existingCandidate
return null }
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 val session = activeSession ?: throw NoiseSessionError.InvalidState
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 response = session.processHandshakeMessage(message)
val response = session.processHandshakeMessage(message)
// Check if session is established
if (session.isEstablished()) { if (session.isEstablished()) {
Log.d(TAG, "✅ Session ESTABLISHED with $peerID")
val remoteStaticKey = session.getRemoteStaticPublicKey() val remoteStaticKey = session.getRemoteStaticPublicKey()
if (remoteStaticKey != null) { ?: throw NoiseSessionError.HandshakeFailed
onSessionEstablished?.invoke(peerID, remoteStaticKey) 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) { } 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}") Log.e(TAG, "Handshake failed with $peerID: ${e.message}")
sessions.remove(peerID) runCatching { onSessionFailed?.invoke(peerID, e) }
onSessionFailed?.invoke(peerID, e)
throw 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 { private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs() val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
if (lastActivity == null) return false if (lastActivity == null) return false
@@ -239,6 +323,7 @@ class NoiseSessionManager(
fun getDebugInfo(): String = buildString { fun getDebugInfo(): String = buildString {
appendLine("=== Noise Session Manager Debug ===") appendLine("=== Noise Session Manager Debug ===")
appendLine("Active sessions: ${sessions.size}") appendLine("Active sessions: ${sessions.size}")
appendLine("Responder candidates: ${responderCandidates.size}")
appendLine("") appendLine("")
if (sessions.isNotEmpty()) { if (sessions.isNotEmpty()) {
@@ -252,9 +337,12 @@ class NoiseSessionManager(
/** /**
* Shutdown manager and clean up all sessions * Shutdown manager and clean up all sessions
*/ */
@Synchronized
fun shutdown() { fun shutdown() {
sessions.values.forEach { it.destroy() } sessions.values.forEach { it.destroy() }
responderCandidates.values.forEach { it.destroy() }
sessions.clear() sessions.clear()
responderCandidates.clear()
Log.d(TAG, "Noise session manager shut down") 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 InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed") object HandshakeFailed : NoiseSessionError("Handshake failed")
object AlreadyEstablished : NoiseSessionError("Session already established") object AlreadyEstablished : NoiseSessionError("Session already established")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
"Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID"
)
} }
@@ -0,0 +1,25 @@
package com.bitchat.android.wifiaware
/**
* Resolves an authenticated callback to the exact still-active ingress link that completed Noise.
* A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
*/
internal object AuthenticatedIngressLinkPolicy {
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun <T : Any> resolve(
authenticatedLinkID: String?,
authenticatedRelayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = authenticatedLinkID ?: return null
val relayAddress = authenticatedRelayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}
@@ -23,6 +23,7 @@ class WifiAwareConnectionTracker(
// Active resources per peer // Active resources per peer
val peerSockets = ConcurrentHashMap<String, SyncedSocket>() val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
private val socketAliases = ConcurrentHashMap<String, String>() private val socketAliases = ConcurrentHashMap<String, String>()
private val socketBindingLock = Any()
val serverSockets = ConcurrentHashMap<String, ServerSocket>() val serverSockets = ConcurrentHashMap<String, ServerSocket>()
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>() val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
@@ -32,24 +33,26 @@ class WifiAwareConnectionTracker(
} }
override fun disconnect(id: String) { override fun disconnect(id: String) {
Log.d(TAG, "Disconnecting peer $id") synchronized(socketBindingLock) {
val canonicalId = resolveCanonicalPeerId(id) Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 1. Close client socket // 1. Close client socket
peerSockets.remove(canonicalId)?.let { peerSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } 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 }
// 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)
} }
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == 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) { fun releaseNetworkRequest(id: String) {
@@ -71,12 +74,16 @@ class WifiAwareConnectionTracker(
* Successfully established a client connection * Successfully established a client connection
*/ */
fun onClientConnected(peerId: String, socket: SyncedSocket) { fun onClientConnected(peerId: String, socket: SyncedSocket) {
// Close previous socket if one exists to prevent zombie readers synchronized(socketBindingLock) {
peerSockets[peerId]?.let { val canonicalPeerId = resolveCanonicalPeerId(peerId)
try { it.close() } catch (_: Exception) {} // 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? { fun getSocketForPeer(peerId: String): SyncedSocket? {
@@ -87,6 +94,37 @@ class WifiAwareConnectionTracker(
fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId) fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId)
fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String { 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) { if (previousPeerId == resolvedPeerId) {
peerSockets[resolvedPeerId] = socket peerSockets[resolvedPeerId] = socket
return resolvedPeerId return resolvedPeerId
@@ -42,6 +42,7 @@ import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -121,6 +122,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Transport state // Transport state
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
>()
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained // 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, _ -> onAnnounceProcessed = { routed, _ ->
routed.peerID?.let { pid -> routed.peerID?.let { pid ->
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } 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 = { announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } 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 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 * Listens for incoming packets from a connected peer and dispatches them through
* the packet processor. * the packet processor.
@@ -1249,7 +1326,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
* @param initialLogicalPeerId Temporary identifier before peer ID resolution * @param initialLogicalPeerId Temporary identifier before peer ID resolution
*/ */
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { 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) { while (isActive) {
val raw = socket.read() ?: break val raw = socket.read() ?: break
@@ -1263,30 +1343,24 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue
if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) {
val previousPeerId = logicalPeerId // The socket's discovery identity remains provisional until Noise proves possession
logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket) // of the claimed static key on this link. A canonical self-signed announcement is
handleToPeerId.forEach { (handle, peerId) -> // only TOFU and cannot safely rebind/remove transport state on its own.
if (peerId == previousPeerId) { Log.w(
handleToPeerId[handle] = senderPeerHex TAG,
} "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof"
} )
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)}")
} }
// Route the packet: // Route the packet:
// - peerID = Originator (who signed it) // - peerID = Originator (who signed it)
// - relayAddress = Neighbor (who sent it to us over this socket) // - 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})") 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. // 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.") Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")
handlePeerDisconnection(logicalPeerId, socket) handlePeerDisconnection(logicalPeerId, socket)
+5
View File
@@ -12,6 +12,11 @@ fun e(tag: String, msg: String): Int {
return 0; 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 { fun w(tag: String, msg: String): Int {
println("WARN: $tag: $msg") println("WARN: $tag: $msg")
return 0; return 0;
@@ -5,6 +5,7 @@ import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.protocol.SpecialRecipients
@@ -20,7 +21,6 @@ import org.junit.runner.RunWith
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.never import org.mockito.kotlin.never
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
@@ -36,9 +36,9 @@ class MessageHandlerTest {
private lateinit var delegate: MessageHandlerDelegate private lateinit var delegate: MessageHandlerDelegate
private val myPeerID = "1111222233334444" private val myPeerID = "1111222233334444"
private val peerID = "aaaabbbbccccdddd"
private val nickname = "peer"
private val noiseKey = ByteArray(32) { 0x0B } private val noiseKey = ByteArray(32) { 0x0B }
private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!!
private val nickname = "peer"
private val signingKey = ByteArray(32) { 0x0A } private val signingKey = ByteArray(32) { 0x0A }
private val signature = ByteArray(64) { 1 } private val signature = ByteArray(64) { 1 }
private val announceClockSkewToleranceMs = 10 * 60 * 1000L private val announceClockSkewToleranceMs = 10 * 60 * 1000L
@@ -73,10 +73,8 @@ class MessageHandlerTest {
assertTrue("Announce within clock skew tolerance should still store peer identity", result) assertTrue("Announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement( 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) assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement( 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( verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull() 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( verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull() 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( private fun announcePacket(
ageMs: Long, ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
capabilities: PeerCapabilities? = null capabilities: PeerCapabilities? = null,
noisePublicKey: ByteArray = noiseKey
): BitchatPacket { ): BitchatPacket {
val announcement = IdentityAnnouncement( val announcement = IdentityAnnouncement(
nickname = nickname, nickname = nickname,
noisePublicKey = noiseKey, noisePublicKey = noisePublicKey,
signingPublicKey = signingKey, signingPublicKey = signingKey,
capabilities = capabilities capabilities = capabilities
) )
@@ -270,4 +341,15 @@ class MessageHandlerTest {
private fun String.hexToBytes(): ByteArray { private fun String.hexToBytes(): ByteArray {
return chunked(2).map { it.toInt(16).toByte() }.toByteArray() 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()
)
} }
@@ -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<PacketProcessor>()
@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<Unit>()
val lastSeen = CompletableDeferred<String>()
@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"
}
}
@@ -3,8 +3,13 @@ package com.bitchat.android.mesh
import android.os.Build import android.os.Build
import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.IdentityAnnouncement 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.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -26,22 +31,25 @@ class SecurityManagerTest {
private val myPeerID = "1111222233334444" private val myPeerID = "1111222233334444"
private val otherPeerID = "aaaabbbbccccdddd" 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 dummyPayload = "Hello World".toByteArray()
private val validSignature = ByteArray(64) { 1 } private val validSignature = ByteArray(64) { 1 }
private val invalidSignature = ByteArray(64) { 0 } 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 // Fake implementation to bypass initialization issues in tests
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) { open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
var shouldVerify: Boolean = true var shouldVerify: Boolean = true
var lastVerifySignature: ByteArray? = null var lastVerifySignature: ByteArray? = null
var lastVerifyData: ByteArray? = null var lastVerifyData: ByteArray? = null
var lastVerifyKey: ByteArray? = null var lastVerifyKey: ByteArray? = null
var handshakeResult = NoiseHandshakeProcessingResult(null, false)
var handshakeError: Exception? = null
var handshakeCalls = 0
var removePeerCalls = 0
override fun initialize() { override fun initialize() {
// Do nothing to avoid KeyStore access in tests // Do nothing to avoid KeyStore access in tests
@@ -59,6 +67,19 @@ class SecurityManagerTest {
} }
return false 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 @Before
@@ -197,6 +218,41 @@ class SecurityManagerTest {
assertTrue("Valid signed packet from known peer should be accepted", result) 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 @Test
fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() {
val announcement = IdentityAnnouncement( val announcement = IdentityAnnouncement(
@@ -261,6 +317,33 @@ class SecurityManagerTest {
assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) 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 @Test
fun `validatePacket - ignores own packets`() { fun `validatePacket - ignores own packets`() {
val packet = BitchatPacket( val packet = BitchatPacket(
@@ -326,6 +409,86 @@ class SecurityManagerTest {
assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) 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) { private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
val info = PeerInfo( val info = PeerInfo(
id = peerID, id = peerID,
@@ -339,4 +502,25 @@ class SecurityManagerTest {
) )
whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) 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()
} }
@@ -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<NoiseSessionManager>()
@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()
}
}
}
@@ -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 }
)
)
}
}
@@ -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<ConnectivityManager>()
)
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<ConnectivityManager>()
)
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<Socket> {
on { getInputStream() } doReturn ByteArrayInputStream(byteArrayOf())
on { getOutputStream() } doReturn ByteArrayOutputStream()
}
return SyncedSocket(raw)
}
}
+52
View File
@@ -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.