Implement handshake request notifications for pending messages (#321)

- 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)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-07-25 14:55:26 +02:00
committed by GitHub
co-authored by jack
parent 07c5e2e097
commit 3513228736
7 changed files with 416 additions and 149 deletions
@@ -371,4 +371,10 @@ class SecureIdentityStateManager {
return cache.verifiedFingerprints.contains(fingerprint)
}
}
func getVerifiedFingerprints() -> Set<String> {
queue.sync {
return cache.verifiedFingerprints
}
}
}
+111
View File
@@ -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
+200 -114
View File
@@ -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)] = []
+11 -6
View File
@@ -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..."
+66 -13
View File
@@ -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 {
+17 -13
View File
@@ -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)")
+5 -3
View File
@@ -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)