From 9f842edc3080fa805df92f838a95c0a6b76a13f1 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 25 Jul 2025 14:53:26 +0200 Subject: [PATCH] Implement handshake request notifications for pending messages - Add handshakeRequest packet type (0x25) to notify recipients about queued messages - Create HandshakeRequest struct with binary encoding for efficient transmission - Send handshake requests when messages are queued due to missing session - Display notifications when someone wants to send messages - Fix verification persistence bug by adding forceSave on app termination - Update UI to handle optional encryption icons (hide when no handshake attempted) --- .../Identity/SecureIdentityStateManager.swift | 6 + bitchat/Protocols/BitchatProtocol.swift | 111 +++++++ bitchat/Services/BluetoothMeshService.swift | 314 +++++++++++------- bitchat/Services/NoiseEncryptionService.swift | 17 +- bitchat/ViewModels/ChatViewModel.swift | 79 ++++- bitchat/Views/ContentView.swift | 30 +- bitchat/Views/FingerprintView.swift | 8 +- 7 files changed, 416 insertions(+), 149 deletions(-) diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 3eda093a..2b99fa98 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -371,4 +371,10 @@ class SecureIdentityStateManager { return cache.verifiedFingerprints.contains(fingerprint) } } + + func getVerifiedFingerprints() -> Set { + queue.sync { + return cache.verifiedFingerprints + } + } } \ No newline at end of file diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 2b902bb9..2eafa8a7 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -100,6 +100,7 @@ enum MessageType: UInt8 { case protocolAck = 0x22 // Generic protocol acknowledgment case protocolNack = 0x23 // Negative acknowledgment (failure) case systemValidation = 0x24 // Session validation ping + case handshakeRequest = 0x25 // Request handshake for pending messages var description: String { switch self { @@ -121,10 +122,20 @@ enum MessageType: UInt8 { case .protocolAck: return "protocolAck" case .protocolNack: return "protocolNack" case .systemValidation: return "systemValidation" + case .handshakeRequest: return "handshakeRequest" } } } +// Lazy handshake state tracking +enum LazyHandshakeState { + case none // No session, no handshake attempted + case handshakeQueued // User action requires handshake + case handshaking // Currently in handshake process + case established // Session ready for use + case failed(Error) // Handshake failed +} + // Special recipient ID for broadcast messages struct SpecialRecipients { static let broadcast = Data(repeating: 0xFF, count: 8) // All 0xFF = broadcast @@ -360,6 +371,106 @@ struct ReadReceipt: Codable { } } +// Handshake request for pending messages +struct HandshakeRequest: Codable { + let requestID: String + let requesterID: String // Who needs the handshake + let requesterNickname: String // Nickname of requester + let targetID: String // Who should initiate handshake + let pendingMessageCount: UInt8 // Number of messages queued + let timestamp: Date + + init(requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UInt8) { + self.requestID = UUID().uuidString + self.requesterID = requesterID + self.requesterNickname = requesterNickname + self.targetID = targetID + self.pendingMessageCount = pendingMessageCount + self.timestamp = Date() + } + + // For binary decoding + private init(requestID: String, requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UInt8, timestamp: Date) { + self.requestID = requestID + self.requesterID = requesterID + self.requesterNickname = requesterNickname + self.targetID = targetID + self.pendingMessageCount = pendingMessageCount + self.timestamp = timestamp + } + + // MARK: - Binary Encoding + + func toBinaryData() -> Data { + var data = Data() + data.appendUUID(requestID) + + // RequesterID as 8-byte hex string + var requesterData = Data() + var tempID = requesterID + while tempID.count >= 2 && requesterData.count < 8 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + requesterData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + while requesterData.count < 8 { + requesterData.append(0) + } + data.append(requesterData) + + // TargetID as 8-byte hex string + var targetData = Data() + tempID = targetID + while tempID.count >= 2 && targetData.count < 8 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + targetData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + while targetData.count < 8 { + targetData.append(0) + } + data.append(targetData) + + data.appendUInt8(pendingMessageCount) + data.appendDate(timestamp) + data.appendString(requesterNickname) + return data + } + + static func fromBinaryData(_ data: Data) -> HandshakeRequest? { + // Create defensive copy + let dataCopy = Data(data) + + // Minimum size: UUID (16) + requesterID (8) + targetID (8) + count (1) + timestamp (8) + min nickname + guard dataCopy.count >= 42 else { return nil } + + var offset = 0 + + guard let requestID = dataCopy.readUUID(at: &offset) else { return nil } + + guard let requesterIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } + let requesterID = requesterIDData.hexEncodedString() + + guard let targetIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } + let targetID = targetIDData.hexEncodedString() + + guard let pendingMessageCount = dataCopy.readUInt8(at: &offset), + let timestamp = dataCopy.readDate(at: &offset), + let requesterNickname = dataCopy.readString(at: &offset) else { return nil } + + return HandshakeRequest(requestID: requestID, + requesterID: requesterID, + requesterNickname: requesterNickname, + targetID: targetID, + pendingMessageCount: pendingMessageCount, + timestamp: timestamp) + } +} + // MARK: - Protocol Acknowledgments // Protocol-level acknowledgment for reliable delivery diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 069c7200..63adbae2 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -400,6 +400,9 @@ class BluetoothMeshService: NSObject { // Pending private messages waiting for handshake private var pendingPrivateMessages: [String: [(content: String, recipientNickname: String, messageID: String)]] = [:] + // Noise session state tracking for lazy handshakes + private var noiseSessionStates: [String: LazyHandshakeState] = [:] + // Cover traffic for privacy private var coverTrafficTimer: Timer? private let coverTrafficPrefix = "☂DUMMY☂" // Prefix to identify dummy messages after decryption @@ -758,10 +761,8 @@ class BluetoothMeshService: NSObject { 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) - } + // Lazy handshake: No longer initiate handshake on peer ID rotation + // Handshake will be initiated when first private message is sent } } } @@ -1443,28 +1444,9 @@ class BluetoothMeshService: NSObject { SecureLogger.logError(error, context: "Failed to encrypt delivery ACK via Noise for \(recipientID)", category: SecureLogger.encryption) } } else { - // Fall back to legacy encryption - let encryptedPayload: Data - do { - encryptedPayload = try self.noiseService.encrypt(ackData, for: recipientID) - } catch { - SecureLogger.logError(error, context: "Failed to encrypt delivery ACK for \(recipientID)", category: SecureLogger.encryption) - return - } - - // Create ACK packet with direct routing to original sender - let packet = BitchatPacket( - type: MessageType.deliveryAck.rawValue, - senderID: Data(hexString: self.myPeerID) ?? Data(), - recipientID: Data(hexString: recipientID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encryptedPayload, - signature: nil, // ACKs don't need signatures - ttl: 3 // Limited TTL for ACKs - ) - - // Send immediately without delay (ACKs should be fast) - self.broadcastPacket(packet) + // Lazy handshake: No session available, drop the ACK + SecureLogger.log("No Noise session with \(recipientID) for delivery ACK - dropping (lazy handshake mode)", + category: SecureLogger.noise, level: .info) } } } @@ -1549,31 +1531,9 @@ class BluetoothMeshService: NSObject { SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption) } } else { - // 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) - } + // Lazy handshake: No session available, drop the read receipt + SecureLogger.log("No Noise session with \(recipientID) for read receipt - dropping (lazy handshake mode)", + category: SecureLogger.noise, level: .info) } } } @@ -1631,6 +1591,49 @@ class BluetoothMeshService: NSObject { } } + // Get Noise session state for UI display + func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { + return collectionsQueue.sync { + // First check our tracked state + if let state = noiseSessionStates[peerID] { + return state + } + + // If no tracked state, check if we have an established session + if noiseService.hasEstablishedSession(with: peerID) { + return .established + } + + // Default to none + return .none + } + } + + // Trigger handshake with a peer (for UI) + func triggerHandshake(with peerID: String) { + SecureLogger.log("UI triggered handshake with \(peerID)", category: SecureLogger.noise, level: .info) + + // Check if we already have a session + if noiseService.hasEstablishedSession(with: peerID) { + SecureLogger.log("Already have session with \(peerID), skipping handshake", category: SecureLogger.noise, level: .debug) + return + } + + // Update state to handshakeQueued + collectionsQueue.sync(flags: .barrier) { + noiseSessionStates[peerID] = .handshakeQueued + } + + // Apply tie-breaker logic for handshake initiation + if myPeerID < peerID { + // We have lower ID, initiate handshake + initiateNoiseHandshake(with: peerID) + } else { + // We have higher ID, send targeted identity announce to prompt them to initiate + sendNoiseIdentityAnnounce(to: peerID) + } + } + func getPeerRSSI() -> [String: NSNumber] { var rssiValues = peerRSSI @@ -2295,6 +2298,8 @@ class BluetoothMeshService: NSObject { messageTypeName = "VERSION_ACK" case .systemValidation: messageTypeName = "SYSTEM_VALIDATION" + case .handshakeRequest: + messageTypeName = "HANDSHAKE_REQUEST" default: messageTypeName = "UNKNOWN(\(packet.type))" } @@ -3146,45 +3151,15 @@ class BluetoothMeshService: NSObject { (self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: announcement.peerID, publicKeyData: announcement.publicKey) } - // If we don't have a session yet, check if we should initiate + // Lazy handshake: No longer initiate handshake on identity announcement + // Just respond with our own identity announcement if !noiseService.hasEstablishedSession(with: announcement.peerID) { - // Lock rotation during handshake - lockRotation() - - // Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes - // Only the peer with the "lower" ID initiates - // Use coordinator to determine if we should initiate - let shouldInitiate = handshakeCoordinator.determineHandshakeRole( - myPeerID: myPeerID, - remotePeerID: announcement.peerID - ) == .initiator - - if shouldInitiate { - // 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 - SecureLogger.log("Initiating handshake after identity announce from \(announcement.peerID)", - category: SecureLogger.noise, level: .info) - self?.attemptHandshakeIfNeeded(with: announcement.peerID, forceIfStale: true) - } - } else { - // Quick reconnection, initiate immediately - SecureLogger.log("Quick reconnection - initiating handshake with \(announcement.peerID)", - category: SecureLogger.noise, level: .info) - attemptHandshakeIfNeeded(with: announcement.peerID, forceIfStale: true) - } - } else { - // Send our identity back so they know we're ready - SecureLogger.log("Responding to identity announce from \(announcement.peerID) with our own", - category: SecureLogger.noise, level: .info) - sendNoiseIdentityAnnounce(to: announcement.peerID) - } + // Send our identity back so they know we're here + SecureLogger.log("Responding to identity announce from \(announcement.peerID) with our own (lazy handshake mode)", + category: SecureLogger.noise, level: .info) + sendNoiseIdentityAnnounce(to: announcement.peerID) } else { - // We already have a session, but ensure ChatViewModel knows about the fingerprint + // We already have a session, ensure ChatViewModel knows about the fingerprint // This handles the case where handshake completed before identity announcement DispatchQueue.main.async { [weak self] in if let publicKeyData = self?.noiseService.getPeerPublicKeyData(announcement.peerID) { @@ -3396,6 +3371,13 @@ class BluetoothMeshService: NSObject { } } + case .handshakeRequest: + // Handle handshake request for pending messages + let senderID = packet.senderID.hexEncodedString() + if !isPeerIDOurs(senderID) { + handleHandshakeRequest(from: senderID, data: packet.payload) + } + default: break } @@ -4430,7 +4412,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { private func isHighPriorityMessage(type: UInt8) -> Bool { switch MessageType(rawValue: type) { case .noiseHandshakeInit, .noiseHandshakeResp, .protocolAck, - .versionHello, .versionAck, .deliveryAck, .systemValidation: + .versionHello, .versionAck, .deliveryAck, .systemValidation, + .handshakeRequest: return true case .message, .announce, .leave, .readReceipt, .deliveryStatusRequest, .fragmentStart, .fragmentContinue, .fragmentEnd, @@ -4985,6 +4968,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { handshakeAttemptTimes.removeValue(forKey: peerID) handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) + // Update session state to established + collectionsQueue.sync(flags: .barrier) { + self.noiseSessionStates[peerID] = .established + } + // Update connection state to authenticated updatePeerConnectionState(peerID, state: .authenticated) @@ -4996,6 +4984,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { return } + // Update state to handshaking + collectionsQueue.sync(flags: .barrier) { + self.noiseSessionStates[peerID] = .handshaking + } + // Check if we have pending messages let hasPendingMessages = collectionsQueue.sync { return pendingPrivateMessages[peerID]?.isEmpty == false @@ -5178,6 +5171,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Session established successfully handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) + // Update session state to established + collectionsQueue.sync(flags: .barrier) { + self.noiseSessionStates[peerID] = .established + } + // Update connection state to authenticated updatePeerConnectionState(peerID, state: .authenticated) @@ -5211,12 +5209,22 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Session already established, ignore handshake SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info) handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) + + // Update session state to established + collectionsQueue.sync(flags: .barrier) { + self.noiseSessionStates[peerID] = .established + } } 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) + // Update session state to failed + collectionsQueue.sync(flags: .barrier) { + self.noiseSessionStates[peerID] = .failed(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) @@ -5428,25 +5436,16 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { sendVersionAck(ack, to: peerID) - // Proceed with Noise handshake after successful version negotiation + // Lazy handshake: No longer initiate handshake after version negotiation + // Just announce our identity DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } - // First announce our identity + // Just announce our identity self.sendNoiseIdentityAnnounce() - // Then check if we should initiate handshake - // If we already have a valid session, skip handshake - if self.noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.log("Already have session with \(peerID) after version negotiation, skipping handshake", - category: SecureLogger.handshake, level: .info) - - // Force a session validation by sending a small encrypted ping - self.validateNoiseSession(with: peerID) - } else { - // Use attemptHandshakeIfNeeded to coordinate properly - self.attemptHandshakeIfNeeded(with: peerID) - } + SecureLogger.log("Version negotiation complete with \(peerID) - lazy handshake mode", + category: SecureLogger.handshake, level: .info) } } else { // No compatible version @@ -5692,6 +5691,50 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } } + private func handleHandshakeRequest(from peerID: String, data: Data) { + guard let request = HandshakeRequest.fromBinaryData(data) else { + SecureLogger.log("Failed to decode handshake request from \(peerID)", category: SecureLogger.noise, level: .error) + return + } + + // Verify this request is for us + guard request.targetID == myPeerID else { + // This request is not for us, might need to relay + return + } + + SecureLogger.log("Received handshake request from \(request.requesterID) (\(request.requesterNickname)) with \(request.pendingMessageCount) pending messages", + category: SecureLogger.noise, level: .info) + + // Notify UI about pending messages + DispatchQueue.main.async { [weak self] in + if let chatVM = self?.delegate as? ChatViewModel { + // Notify the UI that someone wants to send messages + chatVM.handleHandshakeRequest(from: request.requesterID, + nickname: request.requesterNickname, + pendingCount: request.pendingMessageCount) + } + } + + // Check if we already have a session + if noiseService.hasEstablishedSession(with: peerID) { + // We already have a session, no action needed + SecureLogger.log("Already have session with \(peerID), ignoring handshake request", category: SecureLogger.noise, level: .debug) + return + } + + // Apply tie-breaker logic for handshake initiation + if myPeerID < peerID { + // We have lower ID, initiate handshake + SecureLogger.log("Initiating handshake with \(peerID) in response to handshake request", category: SecureLogger.noise, level: .info) + initiateNoiseHandshake(with: peerID) + } else { + // We have higher ID, send identity announce to prompt them + SecureLogger.log("Sending identity announce to \(peerID) in response to handshake request", category: SecureLogger.noise, level: .info) + sendNoiseIdentityAnnounce(to: peerID) + } + } + // Send protocol ACK for important packets private func sendProtocolAck(for packet: BitchatPacket, to peerID: String, hopCount: UInt8 = 0) { // Generate packet ID from packet content hash @@ -6088,7 +6131,35 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Clear stale session first cleanupPeerCryptoState(recipientPeerID) } - SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info) + + // Update state to handshakeQueued + collectionsQueue.sync(flags: .barrier) { + self.noiseSessionStates[recipientPeerID] = .handshakeQueued + } + + SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake (lazy mode)", category: SecureLogger.noise, level: .info) + + // Notify UI that we're establishing encryption + DispatchQueue.main.async { [weak self] in + if let chatVM = self?.delegate as? ChatViewModel { + // This will update the UI to show "establishing encryption" state + chatVM.updateEncryptionStatusForPeer(recipientPeerID) + } + } + + // Queue message for sending after handshake completes + collectionsQueue.sync(flags: .barrier) { [weak self] in + guard let self = self else { return } + if self.pendingPrivateMessages[recipientPeerID] == nil { + self.pendingPrivateMessages[recipientPeerID] = [] + } + self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString)) + let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0 + SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info) + } + + // Send handshake request to notify recipient of pending messages + sendHandshakeRequest(to: recipientPeerID, pendingCount: UInt8(pendingPrivateMessages[recipientPeerID]?.count ?? 1)) // Apply tie-breaker logic for handshake initiation if myPeerID < recipientPeerID { @@ -6099,16 +6170,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { sendNoiseIdentityAnnounce(to: recipientPeerID) } - // Queue message for sending after handshake completes - messageQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - if self.pendingPrivateMessages[recipientPeerID] == nil { - self.pendingPrivateMessages[recipientPeerID] = [] - } - self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString)) - let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0 - SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info) - } return } @@ -6233,6 +6294,31 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { return false } + private func sendHandshakeRequest(to targetPeerID: String, pendingCount: UInt8) { + // Create handshake request + let request = HandshakeRequest(requesterID: myPeerID, + requesterNickname: (delegate as? ChatViewModel)?.nickname ?? myPeerID, + targetID: targetPeerID, + pendingMessageCount: pendingCount) + + let requestData = request.toBinaryData() + + // Create packet for handshake request + let packet = BitchatPacket(type: MessageType.handshakeRequest.rawValue, + ttl: 6, + senderID: myPeerID, + payload: requestData) + + // Try direct delivery first + if sendDirectToRecipient(packet, recipientPeerID: targetPeerID) { + SecureLogger.log("Sent handshake request directly to \(targetPeerID)", category: SecureLogger.noise, level: .info) + } else { + // Use selective relay if direct delivery fails + sendViaSelectiveRelay(packet, recipientPeerID: targetPeerID) + SecureLogger.log("Sent handshake request via relay to \(targetPeerID)", category: SecureLogger.noise, level: .info) + } + } + private func selectBestRelayPeers(excluding: String, maxPeers: Int = 3) -> [String] { // Select peers with best RSSI for relay, excluding the target recipient var candidates: [(peerID: String, rssi: Int)] = [] diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index d7311aff..00b703cb 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -13,15 +13,18 @@ import os.log // MARK: - Encryption Status enum EncryptionStatus: Equatable { - case none - case noiseHandshaking - case noiseSecured - case noiseVerified + case none // Failed or incompatible + case noHandshake // No handshake attempted yet + case noiseHandshaking // Currently establishing + case noiseSecured // Established but not verified + case noiseVerified // Established and verified - var icon: String { + var icon: String? { // Made optional to hide icon when no handshake switch self { case .none: - return "lock.slash" + return "lock.slash" // Failed handshake + case .noHandshake: + return nil // No icon when no handshake attempted case .noiseHandshaking: return "lock.rotation" case .noiseSecured: @@ -34,6 +37,8 @@ enum EncryptionStatus: Equatable { var description: String { switch self { case .none: + return "Encryption failed" + case .noHandshake: return "Not encrypted" case .noiseHandshaking: return "Establishing encryption..." diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 3cff0e06..05a1100a 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -480,6 +480,16 @@ class ChatViewModel: ObservableObject { return } + // Trigger handshake if we don't have a session yet + let sessionState = meshService.getNoiseSessionState(for: peerID) + switch sessionState { + case .none, .failed: + // Initiate handshake when opening PM + meshService.triggerHandshake(with: peerID) + default: + break + } + selectedPrivateChatPeer = peerID // Also track by fingerprint for persistence across reconnections selectedPrivateChatFingerprint = peerIDToPublicKeyFingerprint[peerID] @@ -648,6 +658,9 @@ class ChatViewModel: ObservableObject { // Flush any pending messages immediately flushMessageBatchImmediately() + // Force save any pending identity changes (verifications, favorites, etc) + SecureIdentityStateManager.shared.forceSave() + // Verify identity key is still there _ = KeychainManager.shared.verifyIdentityKeyExists() @@ -1300,14 +1313,14 @@ class ChatViewModel: ObservableObject { // This must be a pure function - no state mutations allowed // to avoid SwiftUI update loops - let hasEstablished = meshService.getNoiseService().hasEstablishedSession(with: peerID) - let hasSession = meshService.getNoiseService().hasSession(with: peerID) + let sessionState = meshService.getNoiseSessionState(for: peerID) let storedStatus = peerEncryptionStatus[peerID] let status: EncryptionStatus - // First check if we have an established session - if hasEstablished { + // Determine status based on session state + switch sessionState { + case .established: // We have encryption, now check if it's verified if let fingerprint = getFingerprint(for: peerID) { if verifiedFingerprints.contains(fingerprint) { @@ -1319,12 +1332,15 @@ class ChatViewModel: ObservableObject { // We have a session but no fingerprint yet - still secured status = .noiseSecured } - } else if hasSession { - // Check if handshaking + case .handshaking, .handshakeQueued: + // Currently establishing encryption status = .noiseHandshaking - } else { - // Fall back to stored status - status = storedStatus ?? .none + case .none: + // No handshake attempted + status = .noHandshake + case .failed: + // Handshake failed - show broken lock + status = .none } // Cache the result @@ -1332,7 +1348,7 @@ class ChatViewModel: ObservableObject { // 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=\(status)", category: SecureLogger.security, level: .debug) + SecureLogger.log("getEncryptionStatus for \(peerID): sessionState=\(sessionState), stored=\(String(describing: storedStatus)), final=\(status)", category: SecureLogger.security, level: .debug) } return status @@ -1562,9 +1578,8 @@ class ChatViewModel: ObservableObject { } func loadVerifiedFingerprints() { - // Load verified fingerprints from secure storage - let allIdentities = SecureIdentityStateManager.shared.getAllSocialIdentities() - verifiedFingerprints = Set(allIdentities.filter { $0.trustLevel == .verified }.map { $0.fingerprint }) + // Load verified fingerprints directly from secure storage + verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() } private func setupNoiseCallbacks() { @@ -1608,6 +1623,44 @@ class ChatViewModel: ObservableObject { } } } + + func handleHandshakeRequest(from peerID: String, nickname: String, pendingCount: UInt8) { + // Create a notification message + let notificationMessage = BitchatMessage( + sender: "system", + content: "📨 \(nickname) wants to send you \(pendingCount) message\(pendingCount == 1 ? "" : "s"). Open the conversation to receive.", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: "system", + mentions: nil + ) + + // Add to messages + messages.append(notificationMessage) + trimMessagesIfNeeded() + + // Show system notification + if let fingerprint = getFingerprint(for: peerID) { + let isFavorite = favoritePeers.contains(fingerprint) + if isFavorite { + // Send favorite notification + NotificationService.shared.sendPrivateMessageNotification( + from: nickname, + message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending", + peerID: peerID + ) + } else { + // Send regular notification + NotificationService.shared.sendMentionNotification( + from: nickname, + message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending. Open conversation to receive." + ) + } + } + } } extension ChatViewModel: BitchatDelegate { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 36b36b9a..1bb00549 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -669,13 +669,15 @@ struct ContentView: View { .foregroundColor(peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) // Encryption status icon (after peer name) - Image(systemName: peer.encryptionStatus.icon) - .font(.system(size: 10)) - .foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green : - peer.encryptionStatus == .noiseSecured ? textColor : - peer.encryptionStatus == .noiseHandshaking ? Color.orange : - Color.red) - .accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")") + if let icon = peer.encryptionStatus.icon { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green : + peer.encryptionStatus == .noiseSecured ? textColor : + peer.encryptionStatus == .noiseHandshaking ? Color.orange : + Color.red) + .accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")") + } Spacer() @@ -897,12 +899,14 @@ struct ContentView: View { .foregroundColor(Color.orange) // Dynamic encryption status icon let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID) - Image(systemName: encryptionStatus.icon) - .font(.system(size: 14)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : - encryptionStatus == .noiseSecured ? Color.orange : - Color.red) - .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") + if let icon = encryptionStatus.icon { + Image(systemName: icon) + .font(.system(size: 14)) + .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : + encryptionStatus == .noiseSecured ? Color.orange : + Color.red) + .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") + } } .frame(maxWidth: .infinity) .accessibilityLabel("Private chat with \(privatePeerNick)") diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index 5e169f5a..093649c6 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -45,9 +45,11 @@ struct FingerprintView: View { let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) HStack { - Image(systemName: encryptionStatus.icon) - .font(.system(size: 20)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) + if let icon = encryptionStatus.icon { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) + } VStack(alignment: .leading, spacing: 4) { Text(peerNickname)