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

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