Fix Noise handshake stability and session synchronization issues

- Add peer presence tracking with lastHeardFromPeer to detect reconnections
- Automatically send identity announcement when detecting peer reconnection after 30s
- Clear stale sessions when receiving handshake from recently-seen peer (likely restart)
- Add peers to activePeers when successfully decrypting their messages
- Fix thread safety in handshake coordinator with concurrent collections
- Extend message delivery timeouts (30s→120s private, 60s→180s room, 300s→600s favorite)
- Add per-peer encryption queues to prevent nonce desynchronization
- Make NoiseSession encrypt/decrypt operations thread-safe with barrier flag
- Initialize lastSuccessfulMessageTime when handshake completes
- Send identity announcement when decryption fails to prompt session reset
- Improve handshake state logging and debugging

This fixes:
- Peers stuck in "establishing encryption" after restart
- Nonce desynchronization causing "Decryption failed at nonce N"
- Asymmetric peer visibility (one peer sees the other but not vice versa)
- Thread safety issues causing crashes with pendingPrivateMessages
- Sessions marked as stale immediately after establishment
This commit is contained in:
jack
2025-07-23 14:57:15 +02:00
parent 9cf59651bb
commit d912da5898
7 changed files with 526 additions and 99 deletions
+46 -4
View File
@@ -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):
+2
View File
@@ -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]
+8 -2
View File
@@ -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
}
+373 -61
View File
@@ -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
}
}
+3 -3
View File
@@ -21,9 +21,9 @@ class DeliveryTracker {
private var sentAckIDs = Set<String>()
// 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
@@ -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
}
}
+92 -27
View File
@@ -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)