diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 76227d63..a598a611 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 04636BE02E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */; }; 04636BE82E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; }; 04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; }; + 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; }; + 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; }; 04891CA92E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; }; 04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; }; 04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; }; @@ -133,6 +135,7 @@ 04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; 04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; + 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; 04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = ""; }; 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = ""; }; @@ -226,6 +229,7 @@ 04B6BA442E2035530090FE39 /* Noise */ = { isa = PBXGroup; children = ( + 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */, 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */, 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */, 04B6BA432E2035530090FE39 /* NoiseSession.swift */, @@ -563,6 +567,7 @@ 04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */, 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */, + 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, 04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */, @@ -595,6 +600,7 @@ 1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */, 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */, + 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, 04636BC02E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */, diff --git a/bitchat/Noise/NoiseHandshakeCoordinator.swift b/bitchat/Noise/NoiseHandshakeCoordinator.swift new file mode 100644 index 00000000..187f1c74 --- /dev/null +++ b/bitchat/Noise/NoiseHandshakeCoordinator.swift @@ -0,0 +1,308 @@ +// +// NoiseHandshakeCoordinator.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment +class NoiseHandshakeCoordinator { + + // MARK: - Handshake State + + enum HandshakeState: Equatable { + case idle + case waitingToInitiate(since: Date) + case initiating(attempt: Int, lastAttempt: Date) + case responding(since: Date) + case waitingForResponse(messagesSent: [Data], timeout: Date) + case established(since: Date) + case failed(reason: String, canRetry: Bool, lastAttempt: Date) + + var isActive: Bool { + switch self { + case .idle, .established, .failed: + return false + default: + return true + } + } + } + + // MARK: - Properties + + private var handshakeStates: [String: HandshakeState] = [:] + private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent) + + // Configuration + private let maxHandshakeAttempts = 3 + private let handshakeTimeout: TimeInterval = 10.0 + private let retryDelay: TimeInterval = 2.0 + private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery + + // Track handshake messages to detect duplicates + private var processedHandshakeMessages: Set = [] + private let messageHistoryLimit = 100 + + // MARK: - Role Determination + + /// Deterministically determine who should initiate the handshake + /// Lower peer ID becomes the initiator to prevent simultaneous attempts + func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole { + // Use simple string comparison for deterministic ordering + return myPeerID < remotePeerID ? .initiator : .responder + } + + /// Check if we should initiate handshake with a peer + func shouldInitiateHandshake(myPeerID: String, remotePeerID: String) -> Bool { + return handshakeQueue.sync { + // Check if we're already in an active handshake + if let state = handshakeStates[remotePeerID], state.isActive { + SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)", + category: SecureLogger.handshake, level: .debug) + return false + } + + // Check role + let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID) + if role != .initiator { + SecureLogger.log("Not initiator for handshake with \(remotePeerID) (my: \(myPeerID), their: \(remotePeerID))", + category: SecureLogger.handshake, level: .debug) + return false + } + + // Check if we've failed recently and can't retry yet + if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] { + if !canRetry { + return false + } + if Date().timeIntervalSince(lastAttempt) < retryDelay { + return false + } + } + + return true + } + } + + /// Record that we're initiating a handshake + func recordHandshakeInitiation(peerID: String) { + handshakeQueue.async(flags: .barrier) { + let attempt = self.getCurrentAttempt(for: peerID) + 1 + self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date()) + SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)", + category: SecureLogger.handshake, level: .info) + } + } + + /// Record that we're responding to a handshake + func recordHandshakeResponse(peerID: String) { + handshakeQueue.async(flags: .barrier) { + self.handshakeStates[peerID] = .responding(since: Date()) + SecureLogger.log("Recording handshake response to \(peerID)", + category: SecureLogger.handshake, level: .info) + } + } + + /// Record successful handshake completion + func recordHandshakeSuccess(peerID: String) { + handshakeQueue.async(flags: .barrier) { + self.handshakeStates[peerID] = .established(since: Date()) + SecureLogger.log("Handshake successfully established with \(peerID)", + category: SecureLogger.handshake, level: .info) + } + } + + /// Record handshake failure + func recordHandshakeFailure(peerID: String, reason: String) { + handshakeQueue.async(flags: .barrier) { + let attempts = self.getCurrentAttempt(for: peerID) + let canRetry = attempts < self.maxHandshakeAttempts + self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date()) + SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", + category: SecureLogger.handshake, level: .warning) + } + } + + /// Check if we should accept an incoming handshake initiation + func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool { + return handshakeQueue.sync { + // If we're already established, reject new handshakes + if case .established = handshakeStates[remotePeerID] { + SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established", + category: SecureLogger.handshake, level: .debug) + return false + } + + let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID) + + // If we're the initiator and already initiating, this is a race condition + if role == .initiator { + if case .initiating = handshakeStates[remotePeerID] { + // They shouldn't be initiating, but accept it to recover from race condition + SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", + category: SecureLogger.handshake, level: .warning) + return true + } + } + + // If we're the responder, we should accept + return true + } + } + + /// Check if this is a duplicate handshake message + func isDuplicateHandshakeMessage(_ data: Data) -> Bool { + return handshakeQueue.sync { + if processedHandshakeMessages.contains(data) { + return true + } + + // Add to processed messages with size limit + if processedHandshakeMessages.count >= messageHistoryLimit { + processedHandshakeMessages.removeAll() + } + processedHandshakeMessages.insert(data) + return false + } + } + + /// Get time to wait before next handshake attempt + func getRetryDelay(for peerID: String) -> TimeInterval? { + return handshakeQueue.sync { + guard let state = handshakeStates[peerID] else { return nil } + + switch state { + case .failed(_, let canRetry, let lastAttempt): + if !canRetry { return nil } + let timeSinceFailure = Date().timeIntervalSince(lastAttempt) + if timeSinceFailure >= retryDelay { + return 0 + } + return retryDelay - timeSinceFailure + + case .initiating(_, let lastAttempt): + let timeSinceAttempt = Date().timeIntervalSince(lastAttempt) + if timeSinceAttempt >= minTimeBetweenHandshakes { + return 0 + } + return minTimeBetweenHandshakes - timeSinceAttempt + + default: + return nil + } + } + } + + /// Reset handshake state for a peer + func resetHandshakeState(for peerID: String) { + handshakeQueue.async(flags: .barrier) { + self.handshakeStates.removeValue(forKey: peerID) + SecureLogger.log("Reset handshake state for \(peerID)", + category: SecureLogger.handshake, level: .debug) + } + } + + /// Clean up stale handshake states + func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] { + return handshakeQueue.sync { + let now = Date() + var stalePeerIDs: [String] = [] + + for (peerID, state) in handshakeStates { + var isStale = false + + switch state { + case .initiating(_, let lastAttempt): + if now.timeIntervalSince(lastAttempt) > staleTimeout { + isStale = true + } + case .responding(let since): + if now.timeIntervalSince(since) > staleTimeout { + isStale = true + } + case .waitingForResponse(_, let timeout): + if now > timeout { + isStale = true + } + default: + break + } + + if isStale { + stalePeerIDs.append(peerID) + SecureLogger.log("Found stale handshake state for \(peerID): \(state)", + category: SecureLogger.handshake, level: .warning) + } + } + + // Clean up stale states + for peerID in stalePeerIDs { + handshakeStates.removeValue(forKey: peerID) + } + + return stalePeerIDs + } + } + + /// Get current handshake state + func getHandshakeState(for peerID: String) -> HandshakeState { + return handshakeQueue.sync { + return handshakeStates[peerID] ?? .idle + } + } + + // MARK: - Private Helpers + + private func getCurrentAttempt(for peerID: String) -> Int { + switch handshakeStates[peerID] { + case .initiating(let attempt, _): + return attempt + case .failed(_, _, _): + // Count previous attempts + return 1 // Simplified for now + default: + return 0 + } + } + + /// Log current handshake states for debugging + func logHandshakeStates() { + handshakeQueue.sync { + SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug) + for (peerID, state) in handshakeStates { + let stateDesc: String + switch state { + case .idle: + stateDesc = "idle" + case .waitingToInitiate(let since): + stateDesc = "waiting to initiate (since \(since))" + case .initiating(let attempt, let lastAttempt): + stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))" + case .responding(let since): + stateDesc = "responding (since: \(since))" + case .waitingForResponse(let messages, let timeout): + stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))" + case .established(let since): + stateDesc = "established (since \(since))" + case .failed(let reason, let canRetry, let lastAttempt): + stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))" + } + SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug) + } + SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug) + } + } + + /// Clear all handshake states - used during panic mode + func clearAllHandshakeStates() { + handshakeQueue.async(flags: .barrier) { + SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning) + self.handshakeStates.removeAll() + self.processedHandshakeMessages.removeAll() + } + } +} \ No newline at end of file diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 173f487a..3611ce11 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -299,6 +299,7 @@ class NoiseHandshakeState { throw NoiseError.handshakeComplete } + SecureLogger.log("NoiseHandshake[\(role)]: Writing message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info) var messageBuffer = Data() let patterns = messagePatterns[currentPattern] @@ -390,6 +391,7 @@ class NoiseHandshakeState { throw NoiseError.handshakeComplete } + SecureLogger.log("NoiseHandshake[\(role)]: Reading message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info) var buffer = message let patterns = messagePatterns[currentPattern] diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index f8ffa7bd..41ff8a7e 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -92,6 +92,7 @@ class NoiseSession { func processHandshakeMessage(_ message: Data) throws -> Data? { return try sessionQueue.sync(flags: .barrier) { + SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .info) // Initialize handshake state if needed (for responders) if state == .uninitialized && role == .responder { @@ -102,6 +103,7 @@ class NoiseSession { remoteStaticKey: nil ) state = .handshaking + SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info) } guard case .handshaking = state, let handshake = handshakeState else { @@ -110,6 +112,7 @@ class NoiseSession { // Process incoming message _ = try handshake.readMessage(message) + SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .info) // Check if handshake is complete if handshake.isHandshakeComplete() { @@ -127,6 +130,7 @@ class NoiseSession { state = .established handshakeState = nil // Clear handshake state + SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .info) SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) return nil @@ -134,6 +138,7 @@ class NoiseSession { // Generate response let response = try handshake.writeMessage() sentHandshakeMessages.append(response) + SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .info) // Check if handshake is complete after writing if handshake.isHandshakeComplete() { @@ -151,6 +156,7 @@ class NoiseSession { state = .established handshakeState = nil // Clear handshake state + SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .info) SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) } @@ -162,7 +168,7 @@ class NoiseSession { // MARK: - Transport func encrypt(_ plaintext: Data) throws -> Data { - return try sessionQueue.sync { + return try sessionQueue.sync(flags: .barrier) { guard case .established = state, let cipher = sendCipher else { throw NoiseSessionError.notEstablished } @@ -172,7 +178,7 @@ class NoiseSession { } func decrypt(_ ciphertext: Data) throws -> Data { - return try sessionQueue.sync { + return try sessionQueue.sync(flags: .barrier) { guard case .established = state, let cipher = receiveCipher else { throw NoiseSessionError.notEstablished } diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 1c5639ef..d9a7211b 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -46,6 +46,9 @@ class BluetoothMeshService: NSObject { private var discoveredPeripherals: [CBPeripheral] = [] private var connectedPeripherals: [String: CBPeripheral] = [:] private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:] + private var lastConnectionTime: [String: Date] = [:] // Track when peers last connected + private var lastSuccessfulMessageTime: [String: Date] = [:] // Track last successful message exchange + private var lastHeardFromPeer: [String: Date] = [:] // Track last time we received ANY packet from peer private var characteristic: CBMutableCharacteristic? private var subscribedCentrals: [CBCentral] = [] // Thread-safe collections using concurrent queues @@ -55,6 +58,10 @@ class BluetoothMeshService: NSObject { private var peerRSSI: [String: NSNumber] = [:] // Track RSSI values for peers private var peripheralRSSI: [String: NSNumber] = [:] // Track RSSI by peripheral ID during discovery + // Per-peer encryption queues to prevent nonce desynchronization + private var peerEncryptionQueues: [String: DispatchQueue] = [:] + private let encryptionQueuesLock = NSLock() + // MARK: - Peer Identity Rotation // Mappings between ephemeral peer IDs and permanent fingerprints private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint @@ -68,6 +75,7 @@ class BluetoothMeshService: NSObject { weak var delegate: BitchatDelegate? private let noiseService = NoiseEncryptionService() + private let handshakeCoordinator = NoiseHandshakeCoordinator() // Protocol version negotiation state private var versionNegotiationState: [String: VersionNegotiationState] = [:] @@ -332,6 +340,12 @@ class BluetoothMeshService: NSObject { self.peerRSSI.removeValue(forKey: existingPeerID) self.peerRSSI[newPeerID] = rssi } + + // Transfer lastHeardFromPeer tracking + if let lastHeard = self.lastHeardFromPeer[existingPeerID] { + self.lastHeardFromPeer.removeValue(forKey: existingPeerID) + self.lastHeardFromPeer[newPeerID] = lastHeard + } } // Add new mapping @@ -344,10 +358,21 @@ class BluetoothMeshService: NSObject { // Notify about the change if it's a rotation if let oldID = oldPeerID { - // Migrate Noise session to new peer ID - self.noiseService.migratePeerSession(from: oldID, to: newPeerID, fingerprint: fingerprint) + // Clear the old session instead of migrating it + // This ensures both peers do a fresh handshake after ID rotation + self.cleanupPeerCryptoState(oldID) + self.handshakeCoordinator.resetHandshakeState(for: newPeerID) + + // Log the peer ID rotation + SecureLogger.log("Cleared session for peer ID rotation: \(oldID) -> \(newPeerID), will establish fresh handshake", + category: SecureLogger.handshake, level: .info) self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint) + + // Trigger handshake after a short delay to allow the peer to settle + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.initiateNoiseHandshake(with: newPeerID) + } } } } @@ -519,6 +544,25 @@ class BluetoothMeshService: NSObject { self?.cleanupStalePeers() } + // Log handshake states periodically for debugging and clean up stale states + #if DEBUG + Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + // Clean up stale handshakes + let stalePeerIDs = self.handshakeCoordinator.cleanupStaleHandshakes() + if !stalePeerIDs.isEmpty { + for peerID in stalePeerIDs { + // Also remove from noise service + self.cleanupPeerCryptoState(peerID) + SecureLogger.log("Cleaned up stale handshake for \(peerID)", category: SecureLogger.handshake, level: .info) + } + } + + self.handshakeCoordinator.logHandshakeStates() + } + #endif + // Schedule first peer ID rotation scheduleNextRotation() @@ -529,6 +573,9 @@ class BluetoothMeshService: NSObject { // Register with ChatViewModel for verification tracking DispatchQueue.main.async { (self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData) + + // Force UI to update encryption status for this specific peer + (self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID) } } @@ -612,6 +659,14 @@ class BluetoothMeshService: NSObject { // Clear last seen timestamps peerLastSeenTimestamps.removeAll() + + // Clear all encryption queues + encryptionQueuesLock.lock() + peerEncryptionQueues.removeAll() + encryptionQueuesLock.unlock() + + // Clear peer tracking + lastHeardFromPeer.removeAll() } func startServices() { @@ -890,7 +945,10 @@ class BluetoothMeshService: NSObject { func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) { - messageQueue.async { [weak self] in + // Use per-peer encryption queue to prevent nonce desynchronization + let encryptionQueue = getEncryptionQueue(for: recipientID) + + encryptionQueue.async { [weak self] in guard let self = self else { return } // Encode the ACK @@ -952,8 +1010,38 @@ class BluetoothMeshService: NSObject { } } + private func getEncryptionQueue(for peerID: String) -> DispatchQueue { + encryptionQueuesLock.lock() + defer { encryptionQueuesLock.unlock() } + + if let queue = peerEncryptionQueues[peerID] { + return queue + } + + let queue = DispatchQueue(label: "bitchat.encryption.\(peerID)", qos: .userInitiated) + peerEncryptionQueues[peerID] = queue + return queue + } + + private func removeEncryptionQueue(for peerID: String) { + encryptionQueuesLock.lock() + defer { encryptionQueuesLock.unlock() } + + peerEncryptionQueues.removeValue(forKey: peerID) + } + + // Centralized cleanup for peer crypto state + private func cleanupPeerCryptoState(_ peerID: String) { + noiseService.removePeer(peerID) + handshakeCoordinator.resetHandshakeState(for: peerID) + removeEncryptionQueue(for: peerID) + } + func sendReadReceipt(_ receipt: ReadReceipt, to recipientID: String) { - messageQueue.async { [weak self] in + // Use per-peer encryption queue to prevent nonce desynchronization + let encryptionQueue = getEncryptionQueue(for: recipientID) + + encryptionQueue.async { [weak self] in guard let self = self else { return } // Encode the receipt @@ -990,33 +1078,38 @@ class BluetoothMeshService: NSObject { ttl: 3 ) + SecureLogger.log("Sending encrypted read receipt for message \(receipt.originalMessageID) to \(recipientID)", category: SecureLogger.noise, level: .info) self.broadcastPacket(outerPacket) } } catch { SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption) } } else { - // Fall back to legacy encryption - let encryptedPayload: Data - do { - encryptedPayload = try self.noiseService.encrypt(receiptData, for: recipientID) - } catch { - return + // No session - initiate handshake and queue the read receipt + SecureLogger.log("No Noise session with \(recipientID) for read receipt, initiating handshake", category: SecureLogger.noise, level: .info) + + // Initiate handshake regardless of our role if we need to send data + self.initiateNoiseHandshake(with: recipientID) + + // Queue the read receipt as a pending message + // Create a synthetic message ID for the read receipt + let readReceiptMessageID = "READ_RECEIPT_\(receipt.originalMessageID)" + + collectionsQueue.sync(flags: .barrier) { + if self.pendingPrivateMessages[recipientID] == nil { + self.pendingPrivateMessages[recipientID] = [] + } + + // Store the read receipt data as a pending "message" + self.pendingPrivateMessages[recipientID]?.append(( + content: "READ_RECEIPT:\(receipt.originalMessageID)", + recipientNickname: receipt.readerNickname, + messageID: readReceiptMessageID + )) + + let count = self.pendingPrivateMessages[recipientID]?.count ?? 0 + SecureLogger.log("Queued read receipt for \(recipientID), pending messages: \(count)", category: SecureLogger.noise, level: .info) } - - // Create read receipt packet with direct routing to original sender - let packet = BitchatPacket( - type: MessageType.readReceipt.rawValue, - senderID: Data(hexString: self.myPeerID) ?? Data(), - recipientID: Data(hexString: recipientID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encryptedPayload, - signature: nil, // Read receipts don't need signatures - ttl: 3 // Limited TTL for receipts - ) - - // Send immediately without delay - self.broadcastPacket(packet) } } } @@ -1129,11 +1222,29 @@ class BluetoothMeshService: NSObject { lastNetworkNotificationTime = nil processedMessages.removeAll() incomingFragments.removeAll() + + // Clear all encryption queues + encryptionQueuesLock.lock() + peerEncryptionQueues.removeAll() + encryptionQueuesLock.unlock() fragmentMetadata.removeAll() + // Clear peer tracking + lastHeardFromPeer.removeAll() + // Clear persistent identity noiseService.clearPersistentIdentity() + // Clear all handshake coordinator states + handshakeCoordinator.clearAllHandshakeStates() + + // Clear handshake attempt times + handshakeAttemptTimes.removeAll() + + // Notify UI that all peers are disconnected + DispatchQueue.main.async { [weak self] in + self?.delegate?.didUpdatePeerList([]) + } } private func getAllConnectedPeerIDs() -> [String] { @@ -1218,6 +1329,7 @@ class BluetoothMeshService: NSObject { announcedPeers.remove(peerID) announcedToPeers.remove(peerID) peerNicknames.removeValue(forKey: peerID) + lastHeardFromPeer.removeValue(forKey: peerID) actuallyRemoved.append(peerID) // Removed stale peer @@ -1547,6 +1659,30 @@ class BluetoothMeshService: NSObject { messageQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } + // Track that we heard from this peer + let senderID = packet.senderID.hexEncodedString() + if !senderID.isEmpty && senderID != self.myPeerID { + // Check if this is a reconnection after a long silence + let wasReconnection: Bool + if let lastHeard = self.lastHeardFromPeer[senderID] { + let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) + wasReconnection = timeSinceLastHeard > 30.0 + } else { + // First time hearing from this peer + wasReconnection = true + } + + self.lastHeardFromPeer[senderID] = Date() + + // If this is a reconnection, send our identity announcement + if wasReconnection && packet.type != MessageType.noiseIdentityAnnounce.rawValue { + SecureLogger.log("Detected reconnection from \(senderID) after silence, sending identity announcement", category: SecureLogger.noise, level: .info) + DispatchQueue.main.async { [weak self] in + self?.sendNoiseIdentityAnnounce(to: senderID) + } + } + } + // Log specific Noise packet types @@ -1560,7 +1696,6 @@ class BluetoothMeshService: NSObject { } // Update last seen timestamp for this peer - let senderID = packet.senderID.hexEncodedString() if senderID != "unknown" && senderID != self.myPeerID { peerLastSeenTimestamps.set(senderID, value: Date()) } @@ -1846,6 +1981,9 @@ class BluetoothMeshService: NSObject { self.announcedPeers.remove(stalePeerID) self.announcedToPeers.remove(stalePeerID) + // Clear tracking data + self.lastHeardFromPeer.removeValue(forKey: stalePeerID) + // Disconnect any peripherals associated with stale ID if let peripheral = self.connectedPeripherals[stalePeerID] { self.intentionalDisconnects.insert(peripheral.identifier.uuidString) @@ -2128,14 +2266,17 @@ class BluetoothMeshService: NSObject { isPeerIDOurs(recipientIDData.hexEncodedString()) { // This read receipt is for us let senderID = packet.senderID.hexEncodedString() + SecureLogger.log("Received read receipt from \(senderID)", category: SecureLogger.session, level: .info) // Check if payload is already decrypted (came through Noise) if let receipt = ReadReceipt.fromBinaryData(packet.payload) { // Already decrypted - process directly + SecureLogger.log("Processing read receipt for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info) DispatchQueue.main.async { self.delegate?.didReceiveReadReceipt(receipt) } } else if let receipt = ReadReceipt.decode(from: packet.payload) { // Fallback to JSON for backward compatibility + SecureLogger.log("Processing read receipt (JSON) for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info) DispatchQueue.main.async { self.delegate?.didReceiveReadReceipt(receipt) } @@ -2231,7 +2372,19 @@ class BluetoothMeshService: NSObject { // Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes // Only the peer with the "lower" ID initiates if myPeerID < announcement.peerID { - initiateNoiseHandshake(with: announcement.peerID) + // Add small delay on fresh startup to let connections stabilize + let lastConnection = lastConnectionTime[announcement.peerID] ?? Date.distantPast + let timeSinceConnection = Date().timeIntervalSince(lastConnection) + + if timeSinceConnection > 60.0 { // Fresh connection + // Delay handshake initiation slightly for connection stability + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.initiateNoiseHandshake(with: announcement.peerID) + } + } else { + // Quick reconnection, initiate immediately + initiateNoiseHandshake(with: announcement.peerID) + } } else { // Send our identity back so they know we're ready sendNoiseIdentityAnnounce(to: announcement.peerID) @@ -2265,10 +2418,37 @@ class BluetoothMeshService: NSObject { return } if !isPeerIDOurs(senderID) { - // Check if we already have a session (established or handshaking) - if noiseService.hasSession(with: senderID) { - SecureLogger.log("Received handshake init from \(senderID) but already have session/handshaking - ignoring duplicate", category: SecureLogger.noise, level: .warning) - return + // Check if we already have an established session + if noiseService.hasEstablishedSession(with: senderID) { + // Determine who should be initiator based on peer ID comparison + let shouldBeInitiator = myPeerID < senderID + + if shouldBeInitiator { + // We should be initiator but peer is initiating - likely they had a session failure + SecureLogger.log("Received handshake init from \(senderID) who should be responder - likely session mismatch, clearing and accepting", category: SecureLogger.noise, level: .warning) + cleanupPeerCryptoState(senderID) + } else { + // Check if we've heard from this peer recently + let lastHeard = lastHeardFromPeer[senderID] ?? Date.distantPast + let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) + + // If we haven't heard from the peer in 30 seconds, they likely disconnected and reconnected + if timeSinceLastHeard > 30.0 { + SecureLogger.log("Received handshake init from \(senderID) after \(Int(timeSinceLastHeard))s silence - likely reconnected, clearing old session", category: SecureLogger.noise, level: .info) + cleanupPeerCryptoState(senderID) + } else { + // We've heard from them recently but they're initiating a new handshake + // This likely means they restarted and lost their session + SecureLogger.log("Received handshake init from \(senderID) despite recent communication - peer likely restarted, clearing old session", category: SecureLogger.noise, level: .info) + cleanupPeerCryptoState(senderID) + } + } + } + + // If we have a handshaking session, reset it to allow new handshake + if noiseService.hasSession(with: senderID) && !noiseService.hasEstablishedSession(with: senderID) { + SecureLogger.log("Received handshake init from \(senderID) while already handshaking - resetting to allow new handshake", category: SecureLogger.noise, level: .info) + cleanupPeerCryptoState(senderID) } // Check if we've completed version negotiation with this peer @@ -2304,7 +2484,11 @@ class BluetoothMeshService: NSObject { } if !isPeerIDOurs(senderID) { - SecureLogger.log("Processing handshake response from \(senderID)", category: SecureLogger.noise, level: .info) + // Check our current handshake state + let currentState = handshakeCoordinator.getHandshakeState(for: senderID) + SecureLogger.log("Processing handshake response from \(senderID), current state: \(currentState)", category: SecureLogger.noise, level: .info) + + // Process the response - this could be message 2 or message 3 in the XX pattern handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false) } @@ -2733,6 +2917,19 @@ extension BluetoothMeshService: CBCentralManagerDelegate { connectedPeripherals.removeValue(forKey: peerID) peripheralCharacteristics.removeValue(forKey: peripheral) + // Don't clear Noise session on disconnect - sessions should survive disconnects + // The Noise protocol is designed to maintain sessions across network interruptions + // Only clear sessions on authentication failure + if peerID.count == 16 { // Real peer ID + // Clear connection time and last heard tracking on disconnect to properly detect stale sessions + lastConnectionTime.removeValue(forKey: peerID) + lastHeardFromPeer.removeValue(forKey: peerID) + // Keep lastSuccessfulMessageTime to validate session on reconnect + let lastSuccess = lastSuccessfulMessageTime[peerID] ?? Date.distantPast + let sessionAge = Date().timeIntervalSince(lastSuccess) + SecureLogger.log("Peer disconnected: \(peerID), keeping Noise session (age: \(Int(sessionAge))s)", category: SecureLogger.noise, level: .info) + } + // Only remove from active peers if it's not a temp ID // Temp IDs shouldn't be in activePeers anyway let (removed, _) = collectionsQueue.sync(flags: .barrier) { @@ -3245,20 +3442,48 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { private func sendPendingPrivateMessages(to peerID: String) { messageQueue.async(flags: .barrier) { [weak self] in - guard let self = self, - let pendingMessages = self.pendingPrivateMessages[peerID] else { return } + guard let self = self else { return } - SecureLogger.log("Sending \(pendingMessages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info) + // Get pending messages with proper queue synchronization + let pendingMessages = self.collectionsQueue.sync { + return self.pendingPrivateMessages[peerID] + } + + guard let messages = pendingMessages else { return } + + SecureLogger.log("Sending \(messages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info) // Clear pending messages for this peer - self.pendingPrivateMessages.removeValue(forKey: peerID) + self.collectionsQueue.sync(flags: .barrier) { + _ = self.pendingPrivateMessages.removeValue(forKey: peerID) + } // Send each pending message - for (content, recipientNickname, messageID) in pendingMessages { - SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug) - // Use async to avoid blocking the queue - DispatchQueue.global().async { [weak self] in - self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + for (content, recipientNickname, messageID) in messages { + // Check if this is a read receipt + if content.hasPrefix("READ_RECEIPT:") { + // Extract the original message ID + let originalMessageID = String(content.dropFirst("READ_RECEIPT:".count)) + SecureLogger.log("Sending queued read receipt for message \(originalMessageID) to \(peerID)", category: SecureLogger.session, level: .debug) + + // Create and send the actual read receipt + let receipt = ReadReceipt( + originalMessageID: originalMessageID, + readerID: self.myPeerID, + readerNickname: recipientNickname // This is actually the reader's nickname + ) + + // Send the read receipt using the normal method + DispatchQueue.global().async { [weak self] in + self?.sendReadReceipt(receipt, to: peerID) + } + } else { + // Regular message + SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug) + // Use async to avoid blocking the queue + DispatchQueue.global().async { [weak self] in + self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + } } } } @@ -3276,16 +3501,43 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { SecureLogger.log("Already have established session with \(peerID)", category: SecureLogger.noise, level: .debug) // Clear any lingering handshake attempt time handshakeAttemptTimes.removeValue(forKey: peerID) + handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) + + // Force UI update since we have an existing session + DispatchQueue.main.async { [weak self] in + (self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeers() + } + return } - // Check if we've recently tried to handshake with this peer - if let lastAttempt = handshakeAttemptTimes[peerID], - Date().timeIntervalSince(lastAttempt) < handshakeTimeout { - SecureLogger.log("Skipping handshake with \(peerID) - too recent", category: SecureLogger.noise, level: .debug) + // Check with coordinator if we should initiate + if !handshakeCoordinator.shouldInitiateHandshake(myPeerID: myPeerID, remotePeerID: peerID) { + SecureLogger.log("Coordinator says we should not initiate handshake with \(peerID)", category: SecureLogger.handshake, level: .debug) + // Exception: If we have pending messages to send, override and initiate anyway + let hasPendingMessages = collectionsQueue.sync { + return pendingPrivateMessages[peerID]?.isEmpty == false + } + if !hasPendingMessages { + return + } + let pendingCount = collectionsQueue.sync { + return pendingPrivateMessages[peerID]?.count ?? 0 + } + SecureLogger.log("Overriding handshake role due to \(pendingCount) pending messages for \(peerID)", category: SecureLogger.handshake, level: .warning) + } + + // Check if there's a retry delay + if let retryDelay = handshakeCoordinator.getRetryDelay(for: peerID), retryDelay > 0 { + SecureLogger.log("Waiting \(retryDelay)s before retrying handshake with \(peerID)", category: SecureLogger.handshake, level: .debug) + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in + self?.initiateNoiseHandshake(with: peerID) + } return } + // Record that we're initiating + handshakeCoordinator.recordHandshakeInitiation(peerID: peerID) handshakeAttemptTimes[peerID] = Date() @@ -3302,16 +3554,32 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: handshakeData, signature: nil, - ttl: 3 // Moderate TTL for handshakes + ttl: 6 // Increased TTL for better delivery on startup ) // Use broadcastPacket instead of sendPacket to ensure it goes through the mesh broadcastPacket(packet) + // Schedule a retry check after 5 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in + guard let self = self else { return } + // Check if handshake completed + if !self.noiseService.hasEstablishedSession(with: peerID) { + let state = self.handshakeCoordinator.getHandshakeState(for: peerID) + if case .initiating = state { + SecureLogger.log("Handshake with \(peerID) not completed after 5s, will retry", category: SecureLogger.handshake, level: .warning) + // The handshake coordinator will handle retry logic + } + } + } + } catch NoiseSessionError.alreadyEstablished { // Session already established, no need to handshake + handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) } catch { - // Failed to initiate handshake silently + // Failed to initiate handshake + handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription) + SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription)) } } @@ -3319,9 +3587,32 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Use noiseService directly SecureLogger.logHandshake("processing \(isInitiation ? "init" : "response")", peerID: peerID, success: true) + // Get current handshake state before processing + let currentState = handshakeCoordinator.getHandshakeState(for: peerID) + let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) + SecureLogger.log("Current handshake state for \(peerID): \(currentState), hasEstablishedSession: \(hasEstablishedSession)", category: SecureLogger.noise, level: .info) + + // Check for duplicate handshake messages + if handshakeCoordinator.isDuplicateHandshakeMessage(message) { + SecureLogger.log("Duplicate handshake message from \(peerID), ignoring", category: SecureLogger.handshake, level: .debug) + return + } + + // If this is an initiation, check if we should accept it + if isInitiation { + if !handshakeCoordinator.shouldAcceptHandshakeInitiation(myPeerID: myPeerID, remotePeerID: peerID) { + SecureLogger.log("Coordinator says we should not accept handshake from \(peerID)", category: SecureLogger.handshake, level: .debug) + return + } + // Record that we're responding + handshakeCoordinator.recordHandshakeResponse(peerID: peerID) + } + do { // Process handshake message if let response = try noiseService.processHandshakeMessage(from: peerID, message: message) { + SecureLogger.log("Handshake processing returned response of size \(response.count), sending back to \(peerID)", category: SecureLogger.noise, level: .info) + // Always send responses as handshake response type let packet = BitchatPacket( type: MessageType.noiseHandshakeResp.rawValue, @@ -3330,26 +3621,35 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: response, signature: nil, - ttl: 3 + ttl: 6 // Increased TTL for better delivery on startup ) // Use broadcastPacket instead of sendPacket to ensure it goes through the mesh broadcastPacket(packet) } else { - SecureLogger.log("No response needed from processHandshakeMessage", category: SecureLogger.noise, level: .debug) + SecureLogger.log("No response needed from processHandshakeMessage (isInitiation: \(isInitiation))", category: SecureLogger.noise, level: .debug) } // Check if handshake is complete - if noiseService.hasEstablishedSession(with: peerID) { + let sessionEstablished = noiseService.hasEstablishedSession(with: peerID) + let newState = handshakeCoordinator.getHandshakeState(for: peerID) + SecureLogger.log("After processing handshake message - sessionEstablished: \(sessionEstablished), newState: \(newState)", category: SecureLogger.noise, level: .info) + + if sessionEstablished { SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) // Unlock rotation now that handshake is complete unlockRotation() // Session established successfully + handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) // Clear handshake attempt time on success handshakeAttemptTimes.removeValue(forKey: peerID) + // Initialize last successful message time + lastSuccessfulMessageTime[peerID] = Date() + SecureLogger.log("Initialized lastSuccessfulMessageTime for \(peerID)", category: SecureLogger.noise, level: .debug) + // Send identity announcement to this specific peer sendNoiseIdentityAnnounce(to: peerID) @@ -3372,9 +3672,18 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } catch NoiseSessionError.alreadyEstablished { // Session already established, ignore handshake SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info) + handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) } catch { // Handshake failed + handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription) + SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription)) SecureLogger.log("Handshake failed with \(peerID): \(error)", category: SecureLogger.noise, level: .error) + + // If handshake failed due to authentication error, clear the session to allow retry + if case NoiseError.authenticationFailure = error { + SecureLogger.log("Handshake failed with \(peerID): authenticationFailure - clearing session", category: SecureLogger.noise, level: .warning) + cleanupPeerCryptoState(peerID) + } } } @@ -3407,6 +3716,23 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { let decryptedData = try noiseService.decrypt(encryptedData, from: peerID) SecureLogger.log("Successfully decrypted message from \(peerID), decrypted size: \(decryptedData.count)", category: SecureLogger.encryption, level: .debug) + // Update last successful message time + lastSuccessfulMessageTime[peerID] = Date() + + // If we can decrypt messages from this peer, they should be in activePeers + let wasAdded = collectionsQueue.sync(flags: .barrier) { + if !self.activePeers.contains(peerID) { + SecureLogger.log("Adding \(peerID) to activePeers after successful decryption", category: SecureLogger.noise, level: .info) + return self.activePeers.insert(peerID).inserted + } + return false + } + + if wasAdded { + // Notify about peer list update + self.notifyPeerListUpdate() + } + // Check if this is a special format message (type marker + payload) if decryptedData.count > 1 { let typeMarker = decryptedData[0] @@ -3464,6 +3790,24 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { initiateNoiseHandshake(with: peerID) } else { SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning) + + // Session is corrupted - clear it and re-initiate handshake + cleanupPeerCryptoState(peerID) + + // Send identity announcement to prompt peer to initiate handshake if needed + sendNoiseIdentityAnnounce(to: peerID) + + // Update UI to show encryption is broken + DispatchQueue.main.async { [weak self] in + if let chatVM = self?.delegate as? ChatViewModel { + chatVM.updateEncryptionStatusForPeer(peerID) + } + } + + // Initiate fresh handshake after a short delay to avoid collision + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.initiateNoiseHandshake(with: peerID) + } } } } @@ -3481,6 +3825,21 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { return } + // Check if this peer is reconnecting after disconnect + if let lastConnected = lastConnectionTime[peerID] { + let timeSinceLastConnection = Date().timeIntervalSince(lastConnected) + if timeSinceLastConnection > 5.0 { // More than 5 seconds since last connection + // Clear any stale Noise session + if noiseService.hasEstablishedSession(with: peerID) { + SecureLogger.log("Peer \(peerID) reconnecting after \(Int(timeSinceLastConnection))s - clearing stale session", category: SecureLogger.noise, level: .info) + cleanupPeerCryptoState(peerID) + } + } + } + + // Update last connection time + lastConnectionTime[peerID] = Date() + // Try JSON first if it looks like JSON let hello: VersionHello? if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character @@ -3576,11 +3935,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { collectionsQueue.sync(flags: .barrier) { _ = self.activePeers.remove(peerID) _ = self.peerNicknames.removeValue(forKey: peerID) + _ = self.lastHeardFromPeer.removeValue(forKey: peerID) } announcedPeers.remove(peerID) // Clean up any Noise session - noiseService.removePeer(peerID) + cleanupPeerCryptoState(peerID) // Notify delegate about incompatible peer disconnection DispatchQueue.main.async { [weak self] in @@ -3724,11 +4084,34 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Send private message using Noise Protocol private func sendPrivateMessageViaNoise(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) { - // Use noiseService directly + // Use per-peer encryption queue to prevent nonce desynchronization + let encryptionQueue = getEncryptionQueue(for: recipientPeerID) - // Check if we have a Noise session with this peer - if !noiseService.hasEstablishedSession(with: recipientPeerID) { - SecureLogger.log("No Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info) + encryptionQueue.async { [weak self] in + guard let self = self else { return } + + // Use noiseService directly + + // Check if we have a Noise session with this peer + let hasSession = self.noiseService.hasEstablishedSession(with: recipientPeerID) + + // Check if session is stale (no successful communication for a while) + var sessionIsStale = false + if hasSession { + let lastSuccess = lastSuccessfulMessageTime[recipientPeerID] ?? Date.distantPast + let sessionAge = Date().timeIntervalSince(lastSuccess) + if sessionAge > 600.0 { // More than 10 minutes since last successful message + sessionIsStale = true + SecureLogger.log("Session with \(recipientPeerID) is stale (last success: \(Int(sessionAge))s ago), will re-establish", category: SecureLogger.noise, level: .info) + } + } + + if !hasSession || sessionIsStale { + if sessionIsStale { + // Clear stale session first + cleanupPeerCryptoState(recipientPeerID) + } + SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info) // Apply tie-breaker logic for handshake initiation if myPeerID < recipientPeerID { @@ -3746,7 +4129,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { self.pendingPrivateMessages[recipientPeerID] = [] } self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString)) - SecureLogger.log("Queued private message for \(recipientPeerID), \(self.pendingPrivateMessages[recipientPeerID]?.count ?? 0) messages pending", category: SecureLogger.noise, level: .info) + let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0 + SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info) } return } @@ -3756,11 +4140,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Check if we're already processing this message let sendKey = "\(msgID)-\(recipientPeerID)" - let alreadySending = collectionsQueue.sync(flags: .barrier) { - if recentlySentMessages.contains(sendKey) { + let alreadySending = self.collectionsQueue.sync(flags: .barrier) { + if self.recentlySentMessages.contains(sendKey) { return true } - recentlySentMessages.insert(sendKey) + self.recentlySentMessages.insert(sendKey) // Clean up old entries after 10 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in self?.collectionsQueue.sync(flags: .barrier) { @@ -3815,6 +4199,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID) SecureLogger.log("Successfully encrypted message, size: \(encryptedData.count)", category: SecureLogger.encryption, level: .debug) + // Update last successful message time + lastSuccessfulMessageTime[recipientPeerID] = Date() + // Send as Noise encrypted message let outerPacket = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, @@ -3832,5 +4219,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Failed to encrypt message SecureLogger.log("Failed to encrypt private message \(msgID) for \(recipientPeerID): \(error)", category: SecureLogger.encryption, level: .error) } + } // End of encryptionQueue.async } } diff --git a/bitchat/Services/DeliveryTracker.swift b/bitchat/Services/DeliveryTracker.swift index 7b4bd8b0..8cc8f1be 100644 --- a/bitchat/Services/DeliveryTracker.swift +++ b/bitchat/Services/DeliveryTracker.swift @@ -21,9 +21,9 @@ class DeliveryTracker { private var sentAckIDs = Set() // Timeout configuration - private let privateMessageTimeout: TimeInterval = 30 // 30 seconds - private let roomMessageTimeout: TimeInterval = 60 // 1 minute - private let favoriteTimeout: TimeInterval = 300 // 5 minutes for favorites + private let privateMessageTimeout: TimeInterval = 120 // 2 minutes + private let roomMessageTimeout: TimeInterval = 180 // 3 minutes + private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites // Retry configuration private let maxRetries = 3 diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 4a52884c..d7311aff 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -25,9 +25,9 @@ enum EncryptionStatus: Equatable { case .noiseHandshaking: return "lock.rotation" case .noiseSecured: - return "lock" + return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock case .noiseVerified: - return "lock.shield" + return "lock.shield.fill" // Changed to filled version for consistency } } diff --git a/bitchat/Utils/SecureLogger.swift b/bitchat/Utils/SecureLogger.swift index 54deb50c..6938b78e 100644 --- a/bitchat/Utils/SecureLogger.swift +++ b/bitchat/Utils/SecureLogger.swift @@ -22,6 +22,16 @@ class SecureLogger { static let keychain = OSLog(subsystem: subsystem, category: "keychain") static let session = OSLog(subsystem: subsystem, category: "session") static let security = OSLog(subsystem: subsystem, category: "security") + static let handshake = OSLog(subsystem: subsystem, category: "handshake") + + // MARK: - Timestamp Formatter + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + formatter.timeZone = TimeZone.current + return formatter + }() // MARK: - Cached Regex Patterns @@ -135,7 +145,8 @@ class SecureLogger { /// Format location information for logging private static func formatLocation(file: String, line: Int, function: String) -> String { let fileName = (file as NSString).lastPathComponent - return "[\(fileName):\(line) \(function)]" + let timestamp = timestampFormatter.string(from: Date()) + return "[\(timestamp)] [\(fileName):\(line) \(function)]" } /// Sanitize strings to remove potentially sensitive data diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1cc8cb5c..7482ea63 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1172,21 +1172,33 @@ class ChatViewModel: ObservableObject { // MARK: - Noise Protocol Support func updateEncryptionStatusForPeers() { + for peerID in connectedPeers { + updateEncryptionStatusForPeer(peerID) + } + } + + func updateEncryptionStatusForPeer(_ peerID: String) { let noiseService = meshService.getNoiseService() - for peerID in connectedPeers { - if noiseService.hasEstablishedSession(with: peerID) { - // Check if fingerprint is verified using our persisted data - if let fingerprint = noiseService.getPeerFingerprint(peerID), - verifiedFingerprints.contains(fingerprint) { - peerEncryptionStatus[peerID] = .noiseVerified - } else { - peerEncryptionStatus[peerID] = .noiseSecured - } + if noiseService.hasEstablishedSession(with: peerID) { + // Check if fingerprint is verified using our persisted data + if let fingerprint = getFingerprint(for: peerID), + verifiedFingerprints.contains(fingerprint) { + peerEncryptionStatus[peerID] = .noiseVerified } else { - // Always use Noise - no legacy encryption - peerEncryptionStatus[peerID] = .noiseHandshaking + peerEncryptionStatus[peerID] = .noiseSecured } + } else if noiseService.hasSession(with: peerID) { + // Session exists but not established - handshaking + peerEncryptionStatus[peerID] = .noiseHandshaking + } else { + // No session at all + peerEncryptionStatus[peerID] = Optional.none + } + + // Force UI update + DispatchQueue.main.async { [weak self] in + self?.objectWillChange.send() } } @@ -1194,31 +1206,64 @@ class ChatViewModel: ObservableObject { // This must be a pure function - no state mutations allowed // to avoid SwiftUI update loops - // Check if we have a fingerprint for this peer - if let fingerprint = getFingerprint(for: peerID) { - // Check if this fingerprint is verified - if verifiedFingerprints.contains(fingerprint) { - // Return verified if we have a Noise session - if meshService.getNoiseService().hasEstablishedSession(with: peerID) { + let hasEstablished = meshService.getNoiseService().hasEstablishedSession(with: peerID) + let hasSession = meshService.getNoiseService().hasSession(with: peerID) + let storedStatus = peerEncryptionStatus[peerID] + + // First check if we have an established session + if hasEstablished { + // We have encryption, now check if it's verified + if let fingerprint = getFingerprint(for: peerID) { + if verifiedFingerprints.contains(fingerprint) { return .noiseVerified + } else { + return .noiseSecured } - } else if meshService.getNoiseService().hasEstablishedSession(with: peerID) { - return .noiseSecured } + // We have a session but no fingerprint yet - still secured + return .noiseSecured + } + + // Check if handshaking + if hasSession { + return .noiseHandshaking } // Fall back to stored status - return peerEncryptionStatus[peerID] ?? .none + let finalStatus = storedStatus ?? .none + + // Only log occasionally to avoid spam + if Int.random(in: 0..<100) == 0 { + SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(finalStatus)", category: SecureLogger.security, level: .debug) + } + + return finalStatus } // Update encryption status in appropriate places, not during view updates private func updateEncryptionStatus(for peerID: String) { - if let fingerprint = getFingerprint(for: peerID) { - if verifiedFingerprints.contains(fingerprint) && meshService.getNoiseService().hasEstablishedSession(with: peerID) { - peerEncryptionStatus[peerID] = .noiseVerified - } else if meshService.getNoiseService().hasEstablishedSession(with: peerID) { + let noiseService = meshService.getNoiseService() + + if noiseService.hasEstablishedSession(with: peerID) { + if let fingerprint = getFingerprint(for: peerID) { + if verifiedFingerprints.contains(fingerprint) { + peerEncryptionStatus[peerID] = .noiseVerified + } else { + peerEncryptionStatus[peerID] = .noiseSecured + } + } else { + // Session established but no fingerprint yet peerEncryptionStatus[peerID] = .noiseSecured } + } else if noiseService.hasSession(with: peerID) { + peerEncryptionStatus[peerID] = .noiseHandshaking + } else { + peerEncryptionStatus[peerID] = Optional.none + } + + // Trigger UI update + DispatchQueue.main.async { [weak self] in + self?.objectWillChange.send() } } @@ -1322,12 +1367,21 @@ class ChatViewModel: ObservableObject { // Set up authentication callback noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in DispatchQueue.main.async { + guard let self = self else { return } + + SecureLogger.log("ChatViewModel: Peer authenticated - \(peerID), fingerprint: \(fingerprint)", category: SecureLogger.security, level: .info) + // Update encryption status - if self?.verifiedFingerprints.contains(fingerprint) == true { - self?.peerEncryptionStatus[peerID] = .noiseVerified + if self.verifiedFingerprints.contains(fingerprint) { + self.peerEncryptionStatus[peerID] = .noiseVerified + SecureLogger.log("ChatViewModel: Setting encryption status to noiseVerified for \(peerID)", category: SecureLogger.security, level: .info) } else { - self?.peerEncryptionStatus[peerID] = .noiseSecured + self.peerEncryptionStatus[peerID] = .noiseSecured + SecureLogger.log("ChatViewModel: Setting encryption status to noiseSecured for \(peerID)", category: SecureLogger.security, level: .info) } + + // Force UI update + self.objectWillChange.send() } } @@ -2024,6 +2078,17 @@ extension ChatViewModel: BitchatDelegate { // Remove ephemeral session from identity manager SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) + // Clear sent read receipts for this peer since they'll need to be resent after reconnection + // Only clear receipts for messages from this specific peer + if let messages = privateChats[peerID] { + for message in messages { + // Remove read receipts for messages FROM this peer (not TO this peer) + if message.senderPeerID == peerID { + sentReadReceipts.remove(message.id) + } + } + } + // Resolve nickname using helper let displayName = resolveNickname(for: peerID) diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index dd129963..32a14c9f 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -208,7 +208,7 @@ final class IntegrationTests: XCTestCase { if packet.type == 0x01 { plainCount += 1 } else if packet.type == 0x02 { - if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1) { + if let _ = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1) { encryptedCount += 1 } } @@ -270,6 +270,124 @@ final class IntegrationTests: XCTestCase { XCTAssertEqual(receivedMessages.count, totalMessages) } + func testPeerPresenceTrackingAndReconnection() { + // Test peer presence tracking and identity announcement on reconnection + connect("Alice", "Bob") + + // Establish Noise sessions + do { + try establishNoiseSession("Alice", "Bob") + } catch { + XCTFail("Failed to establish Noise session: \(error)") + } + + let expectation = XCTestExpectation(description: "Peer reconnection handled") + var bobReceivedIdentityAnnounce = false + var aliceReceivedIdentityAnnounce = false + + // Track identity announcements + nodes["Bob"]!.packetDeliveryHandler = { packet in + if packet.type == MessageType.noiseIdentityAnnounce.rawValue { + bobReceivedIdentityAnnounce = true + if aliceReceivedIdentityAnnounce { + expectation.fulfill() + } + } + } + + nodes["Alice"]!.packetDeliveryHandler = { packet in + if packet.type == MessageType.noiseIdentityAnnounce.rawValue { + aliceReceivedIdentityAnnounce = true + if bobReceivedIdentityAnnounce { + expectation.fulfill() + } + } + } + + // Simulate disconnect (out of range) + disconnect("Alice", "Bob") + + // Wait to simulate extended disconnect period + Thread.sleep(forTimeInterval: 0.5) + + // Reconnect + connect("Alice", "Bob") + + // Both should receive identity announcements after reconnection + wait(for: [expectation], timeout: TestConstants.defaultTimeout) + XCTAssertTrue(bobReceivedIdentityAnnounce) + XCTAssertTrue(aliceReceivedIdentityAnnounce) + } + + func testEncryptedMessageAfterPeerRestart() { + // Test that encrypted messages work after one peer restarts + connect("Alice", "Bob") + do { + try establishNoiseSession("Alice", "Bob") + } catch { + XCTFail("Failed to establish Noise session: \(error)") + } + + // Exchange an encrypted message + let firstExpectation = XCTestExpectation(description: "First message received") + nodes["Bob"]!.messageDeliveryHandler = { message in + if message.content == "Before restart" && message.isPrivate { + firstExpectation.fulfill() + } + } + + nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob") + wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout) + + // Simulate Bob restart by recreating his Noise manager + let bobKey = Curve25519.KeyAgreement.PrivateKey() + noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey) + + // Bob should initiate new handshake + let handshakeExpectation = XCTestExpectation(description: "New handshake completed") + + nodes["Bob"]!.packetDeliveryHandler = { packet in + if packet.type == MessageType.noiseHandshakeInit.rawValue { + // Bob initiates new handshake after restart + do { + let response = try self.noiseManagers["Alice"]!.handleIncomingHandshake( + from: TestConstants.testPeerID2, + message: packet.payload + ) + if let resp = response { + // Send response back to Bob + let responsePacket = TestHelpers.createTestPacket( + type: MessageType.noiseHandshakeResp.rawValue, + payload: resp + ) + self.nodes["Bob"]!.simulateIncomingPacket(responsePacket) + } + } catch { + XCTFail("Handshake handling failed: \(error)") + } + } else if packet.type == MessageType.noiseHandshakeResp.rawValue && packet.senderID.hexEncodedString() == TestConstants.testPeerID1 { + // Final handshake message (message 3 in XX pattern) + handshakeExpectation.fulfill() + } + } + + // Trigger handshake by trying to send a message + nodes["Bob"]!.sendPrivateMessage("After restart", to: TestConstants.testPeerID1, recipientNickname: "Alice") + + wait(for: [handshakeExpectation], timeout: TestConstants.defaultTimeout) + + // Now messages should work again + let secondExpectation = XCTestExpectation(description: "Message after restart received") + nodes["Alice"]!.messageDeliveryHandler = { message in + if message.content == "After restart success" && message.isPrivate { + secondExpectation.fulfill() + } + } + + nodes["Bob"]!.sendPrivateMessage("After restart success", to: TestConstants.testPeerID1, recipientNickname: "Alice") + wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout) + } + func testLargeScaleNetwork() { // Create larger network for i in 5...10 { diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 8b6d3b68..56895af9 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -295,6 +295,165 @@ final class NoiseProtocolTests: XCTestCase { XCTAssertEqual(decrypted, plaintext) } + // MARK: - Session Recovery Tests + + func testPeerRestartDetection() throws { + // Establish initial sessions + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) + let bobManager = NoiseSessionManager(localStaticKey: bobKey) + + try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) + + // Exchange some messages to establish nonce state + let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2) + _ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1) + + let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1) + _ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2) + + // Simulate Bob restart by creating new manager with same key + let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey) + + // Bob initiates new handshake after restart + let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1) + + // Alice should accept the new handshake (clearing old session) + let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1) + XCTAssertNotNil(newHandshake2) + + // Complete the new handshake + let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!) + XCTAssertNotNil(newHandshake3) + _ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake3!) + + // Should be able to exchange messages with new sessions + let testMessage = "After restart".data(using: .utf8)! + let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1) + let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2) + XCTAssertEqual(decrypted, testMessage) + } + + func testNonceDesynchronizationRecovery() throws { + // Create two sessions + aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, localStaticKey: aliceKey) + bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, localStaticKey: bobKey) + + // Establish sessions + try performHandshake(initiator: aliceSession, responder: bobSession) + + // Exchange messages to advance nonces + for i in 0..<5 { + let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!) + _ = try bobSession.decrypt(msg) + } + + // Simulate desynchronization by encrypting but not decrypting + for i in 0..<3 { + _ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!) + } + + // Next message from Alice should fail to decrypt (nonce mismatch) + let desyncMessage = try aliceSession.encrypt("This will fail".data(using: .utf8)!) + XCTAssertThrowsError(try bobSession.decrypt(desyncMessage)) + } + + func testConcurrentEncryption() throws { + // Test thread safety of encryption operations + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) + let bobManager = NoiseSessionManager(localStaticKey: bobKey) + + try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) + + let messageCount = 100 + let expectation = XCTestExpectation(description: "All messages encrypted and decrypted") + expectation.expectedFulfillmentCount = messageCount * 2 + + let group = DispatchGroup() + var encryptedMessages: [Int: Data] = [:] + let encryptionQueue = DispatchQueue(label: "test.encryption", attributes: .concurrent) + let lock = NSLock() + + // Encrypt messages concurrently + for i in 0..