diff --git a/bitchat/Noise/NoiseHandshakeCoordinator.swift b/bitchat/Noise/NoiseHandshakeCoordinator.swift index c205cfa5..187f1c74 100644 --- a/bitchat/Noise/NoiseHandshakeCoordinator.swift +++ b/bitchat/Noise/NoiseHandshakeCoordinator.swift @@ -17,7 +17,7 @@ class NoiseHandshakeCoordinator { case idle case waitingToInitiate(since: Date) case initiating(attempt: Int, lastAttempt: Date) - case responding + case responding(since: Date) case waitingForResponse(messagesSent: [Data], timeout: Date) case established(since: Date) case failed(reason: String, canRetry: Bool, lastAttempt: Date) @@ -101,7 +101,7 @@ class NoiseHandshakeCoordinator { /// Record that we're responding to a handshake func recordHandshakeResponse(peerID: String) { handshakeQueue.async(flags: .barrier) { - self.handshakeStates[peerID] = .responding + self.handshakeStates[peerID] = .responding(since: Date()) SecureLogger.log("Recording handshake response to \(peerID)", category: SecureLogger.handshake, level: .info) } @@ -206,6 +206,48 @@ class NoiseHandshakeCoordinator { } } + /// 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 { @@ -240,8 +282,8 @@ class NoiseHandshakeCoordinator { stateDesc = "waiting to initiate (since \(since))" case .initiating(let attempt, let lastAttempt): stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))" - case .responding: - stateDesc = "responding" + 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): 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 61e0a366..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 @@ -333,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 @@ -347,10 +360,7 @@ class BluetoothMeshService: NSObject { if let oldID = oldPeerID { // Clear the old session instead of migrating it // This ensures both peers do a fresh handshake after ID rotation - self.noiseService.removePeer(oldID) - - // Reset handshake state for both old and new peer IDs - self.handshakeCoordinator.resetHandshakeState(for: oldID) + self.cleanupPeerCryptoState(oldID) self.handshakeCoordinator.resetHandshakeState(for: newPeerID) // Log the peer ID rotation @@ -534,10 +544,22 @@ class BluetoothMeshService: NSObject { self?.cleanupStalePeers() } - // Log handshake states periodically for debugging + // Log handshake states periodically for debugging and clean up stale states #if DEBUG Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - self?.handshakeCoordinator.logHandshakeStates() + 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 @@ -552,8 +574,8 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { (self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData) - // Force UI to update encryption status after authentication - (self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeers() + // Force UI to update encryption status for this specific peer + (self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID) } } @@ -637,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() { @@ -915,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 @@ -977,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 @@ -1015,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) } } } @@ -1154,8 +1222,16 @@ 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() @@ -1253,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 @@ -1582,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 @@ -1595,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()) } @@ -1881,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) @@ -2163,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) } @@ -2266,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) @@ -2300,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 @@ -2339,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) } @@ -2768,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) { @@ -3280,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) + } } } } @@ -3324,7 +3514,17 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // 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) - return + // 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 @@ -3354,12 +3554,25 @@ 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) @@ -3374,6 +3587,11 @@ 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) @@ -3393,6 +3611,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { 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, @@ -3401,17 +3621,21 @@ 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() @@ -3422,6 +3646,10 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // 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) @@ -3450,6 +3678,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { 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) + } } } @@ -3482,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] @@ -3539,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) + } } } } @@ -3556,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 @@ -3651,12 +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) - handshakeCoordinator.resetHandshakeState(for: peerID) + cleanupPeerCryptoState(peerID) // Notify delegate about incompatible peer disconnection DispatchQueue.main.async { [weak self] in @@ -3800,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 { @@ -3822,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 } @@ -3832,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) { @@ -3891,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, @@ -3908,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/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)