mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
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:
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -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()
|
||||
}
|
||||
|
||||
+217
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user