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