mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
Merge pull request #792 from permissionlesstech/kimi/improve-noise-handshake-reliability
Improve Noise handshake reliability and offline session recovery
This commit is contained in:
commit
c39bb8124c
@ -67,8 +67,8 @@ class BluetoothConnectionManager(
|
||||
delegate?.onDeviceConnected(device)
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) {
|
||||
delegate?.onDeviceDisconnected(device, linkID)
|
||||
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) {
|
||||
delegate?.onDeviceDisconnected(device, linkID, peerID)
|
||||
}
|
||||
|
||||
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||
@ -512,6 +512,6 @@ interface BluetoothConnectionManagerDelegate {
|
||||
ingressLinkID: String
|
||||
)
|
||||
fun onDeviceConnected(device: BluetoothDevice)
|
||||
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?)
|
||||
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
|
||||
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
|
||||
}
|
||||
|
||||
@ -527,10 +527,12 @@ class BluetoothGattClientManager(
|
||||
} else {
|
||||
Log.i(TAG, "Disconnected from $deviceAddress (client)")
|
||||
}
|
||||
// Capture the observed peer before cleanup drops the address mapping.
|
||||
val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress]
|
||||
connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID)
|
||||
|
||||
// Notify higher layers about device disconnection to update direct flags
|
||||
delegate?.onDeviceDisconnected(gatt.device, linkID)
|
||||
delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID)
|
||||
|
||||
connectionScope.launch {
|
||||
delay(500) // CLEANUP_DELAY
|
||||
|
||||
@ -203,11 +203,13 @@ class BluetoothGattServerManager(
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.i(TAG, "Disconnected from ${device.address} (server)")
|
||||
val linkID = serverLinkIDs.remove(device.address)
|
||||
// Capture the observed peer before cleanup drops the address mapping.
|
||||
val disconnectedPeerID = connectionTracker.addressPeerMap[device.address]
|
||||
if (linkID != null) {
|
||||
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
|
||||
}
|
||||
// Notify delegate about device disconnection so higher layers can update direct flags
|
||||
delegate?.onDeviceDisconnected(device, linkID)
|
||||
delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
companion object {
|
||||
private const val TAG = "BluetoothMeshService"
|
||||
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS
|
||||
}
|
||||
|
||||
// Core components - each handling specific responsibilities
|
||||
@ -410,6 +411,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun hasNoiseSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun removeNoiseSession(peerID: String) {
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
try {
|
||||
@ -543,8 +552,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
@ -669,16 +678,41 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
override fun onDeviceDisconnected(
|
||||
device: android.bluetooth.BluetoothDevice,
|
||||
linkID: String?
|
||||
linkID: String?,
|
||||
peerID: String?
|
||||
) {
|
||||
Log.i(TAG, "Device disconnected: ${device.address}")
|
||||
Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)")
|
||||
|
||||
// refresh peer list on disconnect.
|
||||
// refresh peer list on disconnect.
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
|
||||
// ConnectionTracker already removes an observed mapping only when this exact
|
||||
// link is still current. Do not remove by reusable address here: this may be a late
|
||||
// disconnect callback from a replaced GATT connection.
|
||||
|
||||
// If the peer that used this link does not come back within a short grace
|
||||
// period (no other link, no traffic), tear down their Noise session instead of
|
||||
// waiting for the 3-minute stale-peer sweep.
|
||||
if (peerID != null) {
|
||||
val deviceAddress = device.address
|
||||
val disconnectedAt = System.currentTimeMillis()
|
||||
serviceScope.launch {
|
||||
delay(PEER_DISCONNECT_GRACE_MS)
|
||||
try {
|
||||
val linkBack =
|
||||
connectionManager.addressPeerMap.containsKey(deviceAddress) ||
|
||||
connectionManager.addressPeerMap.containsValue(peerID)
|
||||
val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L
|
||||
val seenAfterDisconnect = lastSeen > disconnectedAt
|
||||
if (!linkBack && !seenAfterDisconnect) {
|
||||
Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session")
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||
|
||||
@ -364,6 +364,14 @@ class MeshCore(
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun removeNoiseSession(peerID: String) {
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
} catch (e: Exception) {
|
||||
Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
this@MeshCore.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
@ -439,8 +447,8 @@ class MeshCore(
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
|
||||
@ -27,52 +27,62 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
companion object {
|
||||
private const val TAG = "MessageHandler"
|
||||
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
|
||||
private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3
|
||||
}
|
||||
|
||||
|
||||
// Delegate for callbacks
|
||||
var delegate: MessageHandlerDelegate? = null
|
||||
|
||||
|
||||
// Reference to PacketProcessor for recursive packet handling
|
||||
var packetProcessor: PacketProcessor? = null
|
||||
|
||||
|
||||
// Coroutines
|
||||
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
||||
// Consecutive decrypt failures per peer; only signature-verified packets reach this path,
|
||||
// so repeated failures mean the established session is stale (peer re-handshaked elsewhere).
|
||||
private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
||||
|
||||
/**
|
||||
* Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version
|
||||
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
|
||||
*
|
||||
* Returns false when the payload could not be decrypted, so callers can treat the
|
||||
* packet as not liveness-proving (no lastSeen refresh, no relay).
|
||||
*/
|
||||
suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
|
||||
|
||||
// Skip our own messages
|
||||
if (peerID == myPeerID) return
|
||||
|
||||
if (peerID == myPeerID) return true
|
||||
|
||||
// Check if this message is for us
|
||||
val recipientID = packet.recipientID?.toHexString()
|
||||
if (recipientID != myPeerID) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Decrypt the message using the Noise service
|
||||
val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
|
||||
if (decryption == null) {
|
||||
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
|
||||
return
|
||||
registerDecryptFailure(peerID)
|
||||
return false
|
||||
}
|
||||
consecutiveDecryptFailures.remove(peerID)
|
||||
val decryptedData = decryption.plaintext
|
||||
|
||||
|
||||
if (decryptedData.isEmpty()) {
|
||||
Log.w(TAG, "Decrypted data is empty from $peerID")
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
|
||||
if (noisePayload == null) {
|
||||
Log.w(TAG, "Failed to parse NoisePayload from $peerID")
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
when (noisePayload.type) {
|
||||
@ -86,7 +96,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
handleFavoriteNotificationFromMesh(pmContent, peerID)
|
||||
// Acknowledge delivery for UX parity
|
||||
sendDeliveryAck(privateMessage.messageID, peerID)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
// Create BitchatMessage - preserve source packet timestamp
|
||||
@ -180,6 +190,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Count consecutive decrypt failures from a signature-verified peer that we still hold an
|
||||
* established session for. After repeated failures the session is stale (the peer completed
|
||||
* a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side
|
||||
* will finish via the responder-candidate path and evict its own stale session.
|
||||
*/
|
||||
private fun registerDecryptFailure(peerID: String) {
|
||||
if (peerID == "unknown" || peerID == myPeerID) return
|
||||
if (delegate?.hasNoiseSession(peerID) != true) return
|
||||
val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1
|
||||
if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
|
||||
consecutiveDecryptFailures.remove(peerID)
|
||||
Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking")
|
||||
try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
consecutiveDecryptFailures[peerID] = failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -646,6 +681,7 @@ interface MessageHandlerDelegate {
|
||||
// Noise protocol operations
|
||||
fun hasNoiseSession(peerID: String): Boolean
|
||||
fun initiateNoiseHandshake(peerID: String)
|
||||
fun removeNoiseSession(peerID: String) {}
|
||||
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
|
||||
fun onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
|
||||
@ -144,7 +144,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
if (packetRelayManager.isPacketAddressedToMe(packet)) {
|
||||
when (messageType) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
validPacket = false
|
||||
@ -175,9 +175,10 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
/**
|
||||
* Handle Noise encrypted transport message
|
||||
* Returns false when decryption fails so undecryptable packets do not prove liveness.
|
||||
*/
|
||||
private suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
delegate?.handleNoiseEncrypted(routed)
|
||||
private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return delegate?.handleNoiseEncrypted(routed) ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -292,7 +293,7 @@ interface PacketProcessorDelegate {
|
||||
|
||||
// Message type handlers
|
||||
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket)
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
|
||||
@ -25,12 +25,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes
|
||||
private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES
|
||||
private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES
|
||||
private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS
|
||||
}
|
||||
|
||||
// Security tracking
|
||||
private val processedMessages = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
|
||||
private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
|
||||
|
||||
// Delegate for callbacks
|
||||
var delegate: SecurityManagerDelegate? = null
|
||||
@ -134,6 +136,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
// from ambient session state: a rejected replacement may leave the old session active.
|
||||
val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID)
|
||||
processedKeyExchanges.add(exchangeKey)
|
||||
keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis()
|
||||
|
||||
if (result.response != null) {
|
||||
// Send handshake response through delegate
|
||||
@ -393,8 +396,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
/**
|
||||
* Clean up old processed messages and timestamps
|
||||
*/
|
||||
private fun cleanupOldData() {
|
||||
val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT
|
||||
internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) {
|
||||
val cutoffTime = nowMs - MESSAGE_TIMEOUT
|
||||
|
||||
// Clean up old message timestamps and corresponding processed messages
|
||||
val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) ->
|
||||
@ -413,12 +416,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
processedMessages.removeAll(toRemove.toSet())
|
||||
removeFromMessageTimestamps(toRemove)
|
||||
}
|
||||
|
||||
|
||||
// Expire handshake dedup entries by time so a delayed same-ephemeral delivery
|
||||
// (e.g. a re-handshake retry after a failed attempt) is not blocked forever.
|
||||
val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT
|
||||
val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) ->
|
||||
timestamp < keyExchangeCutoff
|
||||
}.map { it.key }
|
||||
|
||||
keyExchangesToRemove.forEach { exchangeKey ->
|
||||
keyExchangeTimestamps.remove(exchangeKey)
|
||||
processedKeyExchanges.remove(exchangeKey)
|
||||
}
|
||||
|
||||
// Limit the size of processed key exchanges set
|
||||
if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) {
|
||||
val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES
|
||||
val toRemove = processedKeyExchanges.take(excess)
|
||||
processedKeyExchanges.removeAll(toRemove.toSet())
|
||||
toRemove.forEach { keyExchangeTimestamps.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,6 +454,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
processedMessages.clear()
|
||||
processedKeyExchanges.clear()
|
||||
messageTimestamps.clear()
|
||||
keyExchangeTimestamps.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -2,6 +2,8 @@ package com.bitchat.android.noise
|
||||
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class NoiseHandshakeProcessingResult(
|
||||
val response: ByteArray?,
|
||||
@ -50,15 +52,30 @@ class NoiseSessionManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoiseSessionManager"
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||
private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L
|
||||
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
|
||||
private const val SESSION_TOKEN_SIZE = 32
|
||||
}
|
||||
|
||||
|
||||
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>()
|
||||
|
||||
private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
init {
|
||||
sweepScheduler.scheduleWithFixedDelay({
|
||||
try {
|
||||
cleanupStaleHandshakes(System.currentTimeMillis())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Handshake sweep failed: ${e.message}")
|
||||
}
|
||||
}, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
|
||||
@ -289,6 +306,31 @@ class NoiseSessionManager(
|
||||
if (lastActivity == null) return false
|
||||
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Actively expire handshakes that stopped progressing (lost response, abandoned
|
||||
* responder candidates). Established sessions are never touched here.
|
||||
*/
|
||||
@Synchronized
|
||||
fun cleanupStaleHandshakes(nowMs: Long) {
|
||||
sessions.entries.toList().forEach { (peerID, session) ->
|
||||
if (session.isHandshaking() && isHandshakeStale(session, nowMs)) {
|
||||
Log.d(TAG, "Expiring stale handshake with $peerID")
|
||||
if (sessions.remove(peerID, session)) {
|
||||
session.destroy()
|
||||
runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) }
|
||||
}
|
||||
}
|
||||
}
|
||||
responderCandidates.entries.toList().forEach { (peerID, session) ->
|
||||
if (session.isHandshaking() && isHandshakeStale(session, nowMs)) {
|
||||
Log.d(TAG, "Expiring stale responder candidate for $peerID")
|
||||
if (responderCandidates.remove(peerID, session)) {
|
||||
session.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPLIFIED: Encrypt data
|
||||
@ -427,6 +469,7 @@ class NoiseSessionManager(
|
||||
*/
|
||||
@Synchronized
|
||||
fun shutdown() {
|
||||
sweepScheduler.shutdownNow()
|
||||
sessions.values.forEach { it.destroy() }
|
||||
responderCandidates.values.forEach { it.destroy() }
|
||||
sessions.clear()
|
||||
@ -443,6 +486,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
|
||||
object SessionNotEstablished : NoiseSessionError("Session not established")
|
||||
object InvalidState : NoiseSessionError("Session in invalid state")
|
||||
object HandshakeFailed : NoiseSessionError("Handshake failed")
|
||||
object HandshakeTimeout : NoiseSessionError("Handshake timed out")
|
||||
object AlreadyEstablished : NoiseSessionError("Session already established")
|
||||
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
|
||||
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
|
||||
|
||||
@ -26,6 +26,10 @@ object ContactDirectory {
|
||||
@Volatile
|
||||
private var meshProvider: (() -> MeshService?)? = null
|
||||
|
||||
@Volatile
|
||||
internal var identityManagerProvider: (Context) -> SecureIdentityStateManager =
|
||||
{ SecureIdentityStateManager(it) }
|
||||
|
||||
fun initialize(context: Context, meshProvider: () -> MeshService?) {
|
||||
appContext = context.applicationContext
|
||||
this.meshProvider = meshProvider
|
||||
@ -79,7 +83,8 @@ object ContactDirectory {
|
||||
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
|
||||
nostrPubkey = favorite?.peerNostrPublicKey,
|
||||
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname },
|
||||
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
|
||||
?: contactFingerprint?.let { cachedFingerprintNickname(it) },
|
||||
isMutualFavorite = favorite?.isMutual == true
|
||||
)
|
||||
}
|
||||
@ -132,7 +137,7 @@ object ContactDirectory {
|
||||
private fun cachedNoiseKey(peerID: String): ByteArray? {
|
||||
val context = appContext ?: return null
|
||||
return try {
|
||||
SecureIdentityStateManager(context)
|
||||
identityManagerProvider(context)
|
||||
.getCachedNoiseKey(peerID)
|
||||
?.let { ContactIdentityResolver.bytesFromHex(it) }
|
||||
} catch (_: Exception) {
|
||||
@ -140,6 +145,17 @@ object ContactDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
private fun cachedFingerprintNickname(fingerprint: String): String? {
|
||||
val context = appContext ?: return null
|
||||
return try {
|
||||
identityManagerProvider(context)
|
||||
.getCachedFingerprintNickname(fingerprint)
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? =
|
||||
try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
|
||||
@ -8,6 +8,7 @@ import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
@ -185,6 +186,9 @@ class VerificationHandler(
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
return try {
|
||||
when {
|
||||
ContactIdentityResolver.isContactConversationId(peerID) -> {
|
||||
ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
|
||||
}
|
||||
peerID.length == 64 && peerID.matches(hexRegex) -> {
|
||||
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
|
||||
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
|
||||
|
||||
@ -14,6 +14,7 @@ object AppConstants {
|
||||
// Peer lifecycle
|
||||
const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes
|
||||
const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L
|
||||
const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L
|
||||
|
||||
// BLE connection tracking
|
||||
const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L
|
||||
@ -52,6 +53,7 @@ object AppConstants {
|
||||
const val CLEANUP_INTERVAL_MS: Long = 300_000L
|
||||
const val MAX_PROCESSED_MESSAGES: Int = 10_000
|
||||
const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000
|
||||
const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L
|
||||
}
|
||||
|
||||
object Noise {
|
||||
|
||||
@ -374,6 +374,77 @@ class MessageHandlerTest {
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking {
|
||||
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null)
|
||||
whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
|
||||
val packet = encryptedPacket()
|
||||
|
||||
repeat(2) {
|
||||
assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
|
||||
}
|
||||
verify(delegate, never()).removeNoiseSession(any())
|
||||
verify(delegate, never()).initiateNoiseHandshake(any())
|
||||
|
||||
assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
|
||||
verify(delegate).removeNoiseSession(peerID)
|
||||
verify(delegate).initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful decrypt resets the failure counter`() = runBlocking {
|
||||
whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
|
||||
val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode()
|
||||
whenever(delegate.decryptFromPeer(any(), eq(peerID)))
|
||||
.thenReturn(null)
|
||||
.thenReturn(null)
|
||||
.thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession))
|
||||
.thenReturn(null)
|
||||
.thenReturn(null)
|
||||
val packet = encryptedPacket()
|
||||
|
||||
repeat(5) {
|
||||
handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))
|
||||
}
|
||||
|
||||
verify(delegate, never()).removeNoiseSession(any())
|
||||
verify(delegate, never()).initiateNoiseHandshake(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt failures without an established session never reset`() = runBlocking {
|
||||
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null)
|
||||
whenever(delegate.hasNoiseSession(peerID)).thenReturn(false)
|
||||
val packet = encryptedPacket()
|
||||
|
||||
repeat(4) {
|
||||
assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
|
||||
}
|
||||
|
||||
verify(delegate, never()).removeNoiseSession(any())
|
||||
verify(delegate, never()).initiateNoiseHandshake(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful decrypt reports the packet as valid`() = runBlocking {
|
||||
val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode()
|
||||
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(
|
||||
NoiseDecryptionResult(plaintext, authenticatedSession)
|
||||
)
|
||||
|
||||
assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link")))
|
||||
}
|
||||
|
||||
private fun encryptedPacket(): BitchatPacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = peerID.hexToBytes(),
|
||||
recipientID = myPeerID.hexToBytes(),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = byteArrayOf(0x41, 0x42, 0x43),
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
private fun announcePacket(
|
||||
ageMs: Long,
|
||||
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
|
||||
|
||||
@ -116,7 +116,7 @@ class PacketProcessorAnnounceSideEffectTest {
|
||||
handshakeHandled.complete(Unit)
|
||||
return acceptHandshake
|
||||
}
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) = true
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
handled.complete(Unit)
|
||||
return acceptAnnounce
|
||||
|
||||
@ -542,6 +542,28 @@ class SecurityManagerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking {
|
||||
val payload = byteArrayOf(0x71, 0x72, 0x73)
|
||||
val routed = handshakePacket(payload)
|
||||
|
||||
assertTrue(securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.handshakeCalls == 1)
|
||||
|
||||
assertFalse("Identical frame inside the dedup window must be dropped",
|
||||
securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.handshakeCalls == 1)
|
||||
|
||||
securityManager.cleanupOldData(
|
||||
System.currentTimeMillis() +
|
||||
com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000
|
||||
)
|
||||
|
||||
assertTrue("Expired dedup entries must not block a delayed retry",
|
||||
securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.handshakeCalls == 2)
|
||||
}
|
||||
|
||||
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
|
||||
val info = PeerInfo(
|
||||
id = peerID,
|
||||
|
||||
@ -0,0 +1,153 @@
|
||||
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.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class NoiseSessionManagerHandshakeTimeoutTest {
|
||||
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 `stale initiator handshake is expired and reported as timeout`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val failure = AtomicReference<Throwable?>()
|
||||
aliceManager.onSessionFailed = { peerID, error ->
|
||||
if (peerID == bob.peerID) failure.set(error)
|
||||
}
|
||||
|
||||
assertNotNull(aliceManager.initiateHandshake(bob.peerID))
|
||||
assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
|
||||
|
||||
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
|
||||
|
||||
assertNull(aliceManager.getSession(bob.peerID))
|
||||
assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fresh handshake is not expired`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
|
||||
assertNotNull(aliceManager.initiateHandshake(bob.peerID))
|
||||
|
||||
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000)
|
||||
|
||||
assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired handshake can be re-initiated immediately`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
|
||||
assertNotNull(aliceManager.initiateHandshake(bob.peerID))
|
||||
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
|
||||
|
||||
assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID))
|
||||
assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `established session is never expired by the sweep`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val bobManager = manager(bob)
|
||||
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
|
||||
val aliceSession = aliceManager.getSession(bob.peerID)
|
||||
val bobSession = bobManager.getSession(alice.peerID)
|
||||
|
||||
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000)
|
||||
bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000)
|
||||
|
||||
assertSame(aliceSession, aliceManager.getSession(bob.peerID))
|
||||
assertSame(bobSession, bobManager.getSession(alice.peerID))
|
||||
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
|
||||
assertTrue(bobManager.hasEstablishedSession(alice.peerID))
|
||||
|
||||
val plaintext = "still alive".toByteArray()
|
||||
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
|
||||
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale responder candidate expires while established session survives`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val bobManager = manager(bob)
|
||||
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
|
||||
val established = aliceManager.getSession(bob.peerID)
|
||||
|
||||
// Bob comes back and starts a replacement handshake: alice keeps the established
|
||||
// session and parks the new handshake as a responder candidate.
|
||||
val replacementManager = manager(bob)
|
||||
val message1 = replacementManager.initiateHandshake(alice.peerID)!!
|
||||
assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1))
|
||||
assertSame(established, aliceManager.getSession(bob.peerID))
|
||||
|
||||
// Bob never finishes (message 2 lost): the candidate must expire without touching
|
||||
// the working session.
|
||||
aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
|
||||
|
||||
assertSame(established, aliceManager.getSession(bob.peerID))
|
||||
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
|
||||
val plaintext = "unharmed".toByteArray()
|
||||
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
|
||||
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
|
||||
}
|
||||
|
||||
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 manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager(
|
||||
localStaticPrivateKey = identity.privateKey,
|
||||
localStaticPublicKey = identity.publicKey,
|
||||
localPeerID = identity.peerID
|
||||
).also { managers += it }
|
||||
|
||||
private fun identity(): TestIdentity {
|
||||
val dh = Noise.createDH("25519")
|
||||
return try {
|
||||
dh.generateKeyPair()
|
||||
val privateKey = ByteArray(32)
|
||||
val publicKey = ByteArray(32)
|
||||
dh.getPrivateKey(privateKey, 0)
|
||||
dh.getPublicKey(publicKey, 0)
|
||||
TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
|
||||
} finally {
|
||||
dh.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class ContactDirectoryTest {
|
||||
|
||||
private lateinit var identityManager: SecureIdentityStateManager
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences(
|
||||
"contact-directory-test-${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
identityManager = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
ContactDirectory.initialize(context) { null }
|
||||
ContactDirectory.identityManagerProvider = { identityManager }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline contact resolves display name from cached fingerprint nickname`() {
|
||||
val fingerprint = "ab".repeat(32)
|
||||
identityManager.cacheFingerprintNickname(fingerprint, "Alice")
|
||||
|
||||
val resolution = ContactDirectory.resolve("contact_$fingerprint")
|
||||
|
||||
assertEquals("Alice", resolution.displayName)
|
||||
assertNull(resolution.meshPeerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline contact without cached nickname has no display name`() {
|
||||
val fingerprint = "cd".repeat(32)
|
||||
|
||||
val resolution = ContactDirectory.resolve("contact_$fingerprint")
|
||||
|
||||
assertNull(resolution.displayName)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user