mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Merge pull request #304 from permissionlesstech/noise-handshake-improvements
Fix Noise handshake stability and session synchronization
This commit is contained in:
@@ -30,6 +30,8 @@
|
|||||||
04636BE02E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */; };
|
04636BE02E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */; };
|
||||||
04636BE82E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; };
|
04636BE82E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; };
|
||||||
04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; };
|
04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; };
|
||||||
|
04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; };
|
||||||
|
04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; };
|
||||||
04891CA92E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; };
|
04891CA92E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; };
|
||||||
04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; };
|
04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; };
|
||||||
04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; };
|
04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; };
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
|
04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
|
||||||
04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
||||||
04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; };
|
04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; };
|
||||||
|
04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
||||||
04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = "<group>"; };
|
04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = "<group>"; };
|
||||||
04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = "<group>"; };
|
04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = "<group>"; };
|
||||||
04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = "<group>"; };
|
04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = "<group>"; };
|
||||||
@@ -226,6 +229,7 @@
|
|||||||
04B6BA442E2035530090FE39 /* Noise */ = {
|
04B6BA442E2035530090FE39 /* Noise */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */,
|
||||||
04B6BA412E2035530090FE39 /* NoiseProtocol.swift */,
|
04B6BA412E2035530090FE39 /* NoiseProtocol.swift */,
|
||||||
04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */,
|
04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */,
|
||||||
04B6BA432E2035530090FE39 /* NoiseSession.swift */,
|
04B6BA432E2035530090FE39 /* NoiseSession.swift */,
|
||||||
@@ -563,6 +567,7 @@
|
|||||||
04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */,
|
04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */,
|
||||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
|
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
|
||||||
04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
||||||
|
04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||||
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */,
|
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */,
|
||||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
||||||
04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */,
|
04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */,
|
||||||
@@ -595,6 +600,7 @@
|
|||||||
1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */,
|
1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */,
|
||||||
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
|
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
|
||||||
04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
||||||
|
04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||||
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */,
|
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */,
|
||||||
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
|
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
|
||||||
04636BC02E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */,
|
04636BC02E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */,
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
//
|
||||||
|
// NoiseHandshakeCoordinator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||||
|
class NoiseHandshakeCoordinator {
|
||||||
|
|
||||||
|
// MARK: - Handshake State
|
||||||
|
|
||||||
|
enum HandshakeState: Equatable {
|
||||||
|
case idle
|
||||||
|
case waitingToInitiate(since: Date)
|
||||||
|
case initiating(attempt: Int, lastAttempt: Date)
|
||||||
|
case responding(since: Date)
|
||||||
|
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
||||||
|
case established(since: Date)
|
||||||
|
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .idle, .established, .failed:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var handshakeStates: [String: HandshakeState] = [:]
|
||||||
|
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
private let maxHandshakeAttempts = 3
|
||||||
|
private let handshakeTimeout: TimeInterval = 10.0
|
||||||
|
private let retryDelay: TimeInterval = 2.0
|
||||||
|
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
||||||
|
|
||||||
|
// Track handshake messages to detect duplicates
|
||||||
|
private var processedHandshakeMessages: Set<Data> = []
|
||||||
|
private let messageHistoryLimit = 100
|
||||||
|
|
||||||
|
// MARK: - Role Determination
|
||||||
|
|
||||||
|
/// Deterministically determine who should initiate the handshake
|
||||||
|
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
||||||
|
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
||||||
|
// Use simple string comparison for deterministic ordering
|
||||||
|
return myPeerID < remotePeerID ? .initiator : .responder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we should initiate handshake with a peer
|
||||||
|
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String) -> Bool {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
// Check if we're already in an active handshake
|
||||||
|
if let state = handshakeStates[remotePeerID], state.isActive {
|
||||||
|
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role
|
||||||
|
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||||
|
if role != .initiator {
|
||||||
|
SecureLogger.log("Not initiator for handshake with \(remotePeerID) (my: \(myPeerID), their: \(remotePeerID))",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've failed recently and can't retry yet
|
||||||
|
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
||||||
|
if !canRetry {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we're initiating a handshake
|
||||||
|
func recordHandshakeInitiation(peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
||||||
|
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
||||||
|
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we're responding to a handshake
|
||||||
|
func recordHandshakeResponse(peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
self.handshakeStates[peerID] = .responding(since: Date())
|
||||||
|
SecureLogger.log("Recording handshake response to \(peerID)",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record successful handshake completion
|
||||||
|
func recordHandshakeSuccess(peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
self.handshakeStates[peerID] = .established(since: Date())
|
||||||
|
SecureLogger.log("Handshake successfully established with \(peerID)",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record handshake failure
|
||||||
|
func recordHandshakeFailure(peerID: String, reason: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
let attempts = self.getCurrentAttempt(for: peerID)
|
||||||
|
let canRetry = attempts < self.maxHandshakeAttempts
|
||||||
|
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
||||||
|
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)",
|
||||||
|
category: SecureLogger.handshake, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we should accept an incoming handshake initiation
|
||||||
|
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
// If we're already established, reject new handshakes
|
||||||
|
if case .established = handshakeStates[remotePeerID] {
|
||||||
|
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||||
|
|
||||||
|
// If we're the initiator and already initiating, this is a race condition
|
||||||
|
if role == .initiator {
|
||||||
|
if case .initiating = handshakeStates[remotePeerID] {
|
||||||
|
// They shouldn't be initiating, but accept it to recover from race condition
|
||||||
|
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)",
|
||||||
|
category: SecureLogger.handshake, level: .warning)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're the responder, we should accept
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a duplicate handshake message
|
||||||
|
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
if processedHandshakeMessages.contains(data) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to processed messages with size limit
|
||||||
|
if processedHandshakeMessages.count >= messageHistoryLimit {
|
||||||
|
processedHandshakeMessages.removeAll()
|
||||||
|
}
|
||||||
|
processedHandshakeMessages.insert(data)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time to wait before next handshake attempt
|
||||||
|
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
guard let state = handshakeStates[peerID] else { return nil }
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .failed(_, let canRetry, let lastAttempt):
|
||||||
|
if !canRetry { return nil }
|
||||||
|
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
||||||
|
if timeSinceFailure >= retryDelay {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return retryDelay - timeSinceFailure
|
||||||
|
|
||||||
|
case .initiating(_, let lastAttempt):
|
||||||
|
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
||||||
|
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return minTimeBetweenHandshakes - timeSinceAttempt
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset handshake state for a peer
|
||||||
|
func resetHandshakeState(for peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
self.handshakeStates.removeValue(forKey: peerID)
|
||||||
|
SecureLogger.log("Reset handshake state for \(peerID)",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return handshakeStates[peerID] ?? .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func getCurrentAttempt(for peerID: String) -> Int {
|
||||||
|
switch handshakeStates[peerID] {
|
||||||
|
case .initiating(let attempt, _):
|
||||||
|
return attempt
|
||||||
|
case .failed(_, _, _):
|
||||||
|
// Count previous attempts
|
||||||
|
return 1 // Simplified for now
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log current handshake states for debugging
|
||||||
|
func logHandshakeStates() {
|
||||||
|
handshakeQueue.sync {
|
||||||
|
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug)
|
||||||
|
for (peerID, state) in handshakeStates {
|
||||||
|
let stateDesc: String
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
stateDesc = "idle"
|
||||||
|
case .waitingToInitiate(let since):
|
||||||
|
stateDesc = "waiting to initiate (since \(since))"
|
||||||
|
case .initiating(let attempt, let lastAttempt):
|
||||||
|
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
||||||
|
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):
|
||||||
|
stateDesc = "established (since \(since))"
|
||||||
|
case .failed(let reason, let canRetry, let lastAttempt):
|
||||||
|
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
||||||
|
}
|
||||||
|
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug)
|
||||||
|
}
|
||||||
|
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all handshake states - used during panic mode
|
||||||
|
func clearAllHandshakeStates() {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning)
|
||||||
|
self.handshakeStates.removeAll()
|
||||||
|
self.processedHandshakeMessages.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,7 @@ class NoiseHandshakeState {
|
|||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SecureLogger.log("NoiseHandshake[\(role)]: Writing message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
|
||||||
|
|
||||||
var messageBuffer = Data()
|
var messageBuffer = Data()
|
||||||
let patterns = messagePatterns[currentPattern]
|
let patterns = messagePatterns[currentPattern]
|
||||||
@@ -390,6 +391,7 @@ class NoiseHandshakeState {
|
|||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SecureLogger.log("NoiseHandshake[\(role)]: Reading message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
|
||||||
|
|
||||||
var buffer = message
|
var buffer = message
|
||||||
let patterns = messagePatterns[currentPattern]
|
let patterns = messagePatterns[currentPattern]
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class NoiseSession {
|
|||||||
|
|
||||||
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
||||||
return try sessionQueue.sync(flags: .barrier) {
|
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)
|
// Initialize handshake state if needed (for responders)
|
||||||
if state == .uninitialized && role == .responder {
|
if state == .uninitialized && role == .responder {
|
||||||
@@ -102,6 +103,7 @@ class NoiseSession {
|
|||||||
remoteStaticKey: nil
|
remoteStaticKey: nil
|
||||||
)
|
)
|
||||||
state = .handshaking
|
state = .handshaking
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard case .handshaking = state, let handshake = handshakeState else {
|
guard case .handshaking = state, let handshake = handshakeState else {
|
||||||
@@ -110,6 +112,7 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Process incoming message
|
// Process incoming message
|
||||||
_ = try handshake.readMessage(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
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
@@ -127,6 +130,7 @@ class NoiseSession {
|
|||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
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))
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -134,6 +138,7 @@ class NoiseSession {
|
|||||||
// Generate response
|
// Generate response
|
||||||
let response = try handshake.writeMessage()
|
let response = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(response)
|
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
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
@@ -151,6 +156,7 @@ class NoiseSession {
|
|||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
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))
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +168,7 @@ class NoiseSession {
|
|||||||
// MARK: - Transport
|
// MARK: - Transport
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data) throws -> Data {
|
func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
return try sessionQueue.sync {
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
guard case .established = state, let cipher = sendCipher else {
|
guard case .established = state, let cipher = sendCipher else {
|
||||||
throw NoiseSessionError.notEstablished
|
throw NoiseSessionError.notEstablished
|
||||||
}
|
}
|
||||||
@@ -172,7 +178,7 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data) throws -> Data {
|
func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
return try sessionQueue.sync {
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
guard case .established = state, let cipher = receiveCipher else {
|
guard case .established = state, let cipher = receiveCipher else {
|
||||||
throw NoiseSessionError.notEstablished
|
throw NoiseSessionError.notEstablished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class BluetoothMeshService: NSObject {
|
|||||||
private var discoveredPeripherals: [CBPeripheral] = []
|
private var discoveredPeripherals: [CBPeripheral] = []
|
||||||
private var connectedPeripherals: [String: CBPeripheral] = [:]
|
private var connectedPeripherals: [String: CBPeripheral] = [:]
|
||||||
private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:]
|
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 characteristic: CBMutableCharacteristic?
|
||||||
private var subscribedCentrals: [CBCentral] = []
|
private var subscribedCentrals: [CBCentral] = []
|
||||||
// Thread-safe collections using concurrent queues
|
// 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 peerRSSI: [String: NSNumber] = [:] // Track RSSI values for peers
|
||||||
private var peripheralRSSI: [String: NSNumber] = [:] // Track RSSI by peripheral ID during discovery
|
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
|
// MARK: - Peer Identity Rotation
|
||||||
// Mappings between ephemeral peer IDs and permanent fingerprints
|
// Mappings between ephemeral peer IDs and permanent fingerprints
|
||||||
private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint
|
private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint
|
||||||
@@ -68,6 +75,7 @@ class BluetoothMeshService: NSObject {
|
|||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
private let noiseService = NoiseEncryptionService()
|
private let noiseService = NoiseEncryptionService()
|
||||||
|
private let handshakeCoordinator = NoiseHandshakeCoordinator()
|
||||||
|
|
||||||
// Protocol version negotiation state
|
// Protocol version negotiation state
|
||||||
private var versionNegotiationState: [String: VersionNegotiationState] = [:]
|
private var versionNegotiationState: [String: VersionNegotiationState] = [:]
|
||||||
@@ -332,6 +340,12 @@ class BluetoothMeshService: NSObject {
|
|||||||
self.peerRSSI.removeValue(forKey: existingPeerID)
|
self.peerRSSI.removeValue(forKey: existingPeerID)
|
||||||
self.peerRSSI[newPeerID] = rssi
|
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
|
// Add new mapping
|
||||||
@@ -344,10 +358,21 @@ class BluetoothMeshService: NSObject {
|
|||||||
|
|
||||||
// Notify about the change if it's a rotation
|
// Notify about the change if it's a rotation
|
||||||
if let oldID = oldPeerID {
|
if let oldID = oldPeerID {
|
||||||
// Migrate Noise session to new peer ID
|
// Clear the old session instead of migrating it
|
||||||
self.noiseService.migratePeerSession(from: oldID, to: newPeerID, fingerprint: fingerprint)
|
// This ensures both peers do a fresh handshake after ID rotation
|
||||||
|
self.cleanupPeerCryptoState(oldID)
|
||||||
|
self.handshakeCoordinator.resetHandshakeState(for: newPeerID)
|
||||||
|
|
||||||
|
// Log the peer ID rotation
|
||||||
|
SecureLogger.log("Cleared session for peer ID rotation: \(oldID) -> \(newPeerID), will establish fresh handshake",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
|
||||||
self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,6 +544,25 @@ class BluetoothMeshService: NSObject {
|
|||||||
self?.cleanupStalePeers()
|
self?.cleanupStalePeers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log handshake states periodically for debugging and clean up stale states
|
||||||
|
#if DEBUG
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
|
||||||
|
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
|
||||||
|
|
||||||
// Schedule first peer ID rotation
|
// Schedule first peer ID rotation
|
||||||
scheduleNextRotation()
|
scheduleNextRotation()
|
||||||
|
|
||||||
@@ -529,6 +573,9 @@ class BluetoothMeshService: NSObject {
|
|||||||
// Register with ChatViewModel for verification tracking
|
// Register with ChatViewModel for verification tracking
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
(self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData)
|
(self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData)
|
||||||
|
|
||||||
|
// Force UI to update encryption status for this specific peer
|
||||||
|
(self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +659,14 @@ class BluetoothMeshService: NSObject {
|
|||||||
|
|
||||||
// Clear last seen timestamps
|
// Clear last seen timestamps
|
||||||
peerLastSeenTimestamps.removeAll()
|
peerLastSeenTimestamps.removeAll()
|
||||||
|
|
||||||
|
// Clear all encryption queues
|
||||||
|
encryptionQueuesLock.lock()
|
||||||
|
peerEncryptionQueues.removeAll()
|
||||||
|
encryptionQueuesLock.unlock()
|
||||||
|
|
||||||
|
// Clear peer tracking
|
||||||
|
lastHeardFromPeer.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
@@ -890,7 +945,10 @@ class BluetoothMeshService: NSObject {
|
|||||||
|
|
||||||
|
|
||||||
func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) {
|
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 }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Encode the ACK
|
// Encode the ACK
|
||||||
@@ -952,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) {
|
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 }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Encode the receipt
|
// Encode the receipt
|
||||||
@@ -990,33 +1078,38 @@ class BluetoothMeshService: NSObject {
|
|||||||
ttl: 3
|
ttl: 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SecureLogger.log("Sending encrypted read receipt for message \(receipt.originalMessageID) to \(recipientID)", category: SecureLogger.noise, level: .info)
|
||||||
self.broadcastPacket(outerPacket)
|
self.broadcastPacket(outerPacket)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption)
|
SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fall back to legacy encryption
|
// No session - initiate handshake and queue the read receipt
|
||||||
let encryptedPayload: Data
|
SecureLogger.log("No Noise session with \(recipientID) for read receipt, initiating handshake", category: SecureLogger.noise, level: .info)
|
||||||
do {
|
|
||||||
encryptedPayload = try self.noiseService.encrypt(receiptData, for: recipientID)
|
// Initiate handshake regardless of our role if we need to send data
|
||||||
} catch {
|
self.initiateNoiseHandshake(with: recipientID)
|
||||||
return
|
|
||||||
|
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1129,11 +1222,29 @@ class BluetoothMeshService: NSObject {
|
|||||||
lastNetworkNotificationTime = nil
|
lastNetworkNotificationTime = nil
|
||||||
processedMessages.removeAll()
|
processedMessages.removeAll()
|
||||||
incomingFragments.removeAll()
|
incomingFragments.removeAll()
|
||||||
|
|
||||||
|
// Clear all encryption queues
|
||||||
|
encryptionQueuesLock.lock()
|
||||||
|
peerEncryptionQueues.removeAll()
|
||||||
|
encryptionQueuesLock.unlock()
|
||||||
fragmentMetadata.removeAll()
|
fragmentMetadata.removeAll()
|
||||||
|
|
||||||
|
// Clear peer tracking
|
||||||
|
lastHeardFromPeer.removeAll()
|
||||||
|
|
||||||
// Clear persistent identity
|
// Clear persistent identity
|
||||||
noiseService.clearPersistentIdentity()
|
noiseService.clearPersistentIdentity()
|
||||||
|
|
||||||
|
// Clear all handshake coordinator states
|
||||||
|
handshakeCoordinator.clearAllHandshakeStates()
|
||||||
|
|
||||||
|
// Clear handshake attempt times
|
||||||
|
handshakeAttemptTimes.removeAll()
|
||||||
|
|
||||||
|
// Notify UI that all peers are disconnected
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.delegate?.didUpdatePeerList([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getAllConnectedPeerIDs() -> [String] {
|
private func getAllConnectedPeerIDs() -> [String] {
|
||||||
@@ -1218,6 +1329,7 @@ class BluetoothMeshService: NSObject {
|
|||||||
announcedPeers.remove(peerID)
|
announcedPeers.remove(peerID)
|
||||||
announcedToPeers.remove(peerID)
|
announcedToPeers.remove(peerID)
|
||||||
peerNicknames.removeValue(forKey: peerID)
|
peerNicknames.removeValue(forKey: peerID)
|
||||||
|
lastHeardFromPeer.removeValue(forKey: peerID)
|
||||||
|
|
||||||
actuallyRemoved.append(peerID)
|
actuallyRemoved.append(peerID)
|
||||||
// Removed stale peer
|
// Removed stale peer
|
||||||
@@ -1547,6 +1659,30 @@ class BluetoothMeshService: NSObject {
|
|||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
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
|
// Log specific Noise packet types
|
||||||
|
|
||||||
@@ -1560,7 +1696,6 @@ class BluetoothMeshService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update last seen timestamp for this peer
|
// Update last seen timestamp for this peer
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
|
||||||
if senderID != "unknown" && senderID != self.myPeerID {
|
if senderID != "unknown" && senderID != self.myPeerID {
|
||||||
peerLastSeenTimestamps.set(senderID, value: Date())
|
peerLastSeenTimestamps.set(senderID, value: Date())
|
||||||
}
|
}
|
||||||
@@ -1846,6 +1981,9 @@ class BluetoothMeshService: NSObject {
|
|||||||
self.announcedPeers.remove(stalePeerID)
|
self.announcedPeers.remove(stalePeerID)
|
||||||
self.announcedToPeers.remove(stalePeerID)
|
self.announcedToPeers.remove(stalePeerID)
|
||||||
|
|
||||||
|
// Clear tracking data
|
||||||
|
self.lastHeardFromPeer.removeValue(forKey: stalePeerID)
|
||||||
|
|
||||||
// Disconnect any peripherals associated with stale ID
|
// Disconnect any peripherals associated with stale ID
|
||||||
if let peripheral = self.connectedPeripherals[stalePeerID] {
|
if let peripheral = self.connectedPeripherals[stalePeerID] {
|
||||||
self.intentionalDisconnects.insert(peripheral.identifier.uuidString)
|
self.intentionalDisconnects.insert(peripheral.identifier.uuidString)
|
||||||
@@ -2128,14 +2266,17 @@ class BluetoothMeshService: NSObject {
|
|||||||
isPeerIDOurs(recipientIDData.hexEncodedString()) {
|
isPeerIDOurs(recipientIDData.hexEncodedString()) {
|
||||||
// This read receipt is for us
|
// This read receipt is for us
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
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)
|
// Check if payload is already decrypted (came through Noise)
|
||||||
if let receipt = ReadReceipt.fromBinaryData(packet.payload) {
|
if let receipt = ReadReceipt.fromBinaryData(packet.payload) {
|
||||||
// Already decrypted - process directly
|
// Already decrypted - process directly
|
||||||
|
SecureLogger.log("Processing read receipt for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.didReceiveReadReceipt(receipt)
|
self.delegate?.didReceiveReadReceipt(receipt)
|
||||||
}
|
}
|
||||||
} else if let receipt = ReadReceipt.decode(from: packet.payload) {
|
} else if let receipt = ReadReceipt.decode(from: packet.payload) {
|
||||||
// Fallback to JSON for backward compatibility
|
// 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 {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.didReceiveReadReceipt(receipt)
|
self.delegate?.didReceiveReadReceipt(receipt)
|
||||||
}
|
}
|
||||||
@@ -2231,7 +2372,19 @@ class BluetoothMeshService: NSObject {
|
|||||||
// Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes
|
// Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes
|
||||||
// Only the peer with the "lower" ID initiates
|
// Only the peer with the "lower" ID initiates
|
||||||
if myPeerID < announcement.peerID {
|
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 {
|
} else {
|
||||||
// Send our identity back so they know we're ready
|
// Send our identity back so they know we're ready
|
||||||
sendNoiseIdentityAnnounce(to: announcement.peerID)
|
sendNoiseIdentityAnnounce(to: announcement.peerID)
|
||||||
@@ -2265,10 +2418,37 @@ class BluetoothMeshService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isPeerIDOurs(senderID) {
|
if !isPeerIDOurs(senderID) {
|
||||||
// Check if we already have a session (established or handshaking)
|
// Check if we already have an established session
|
||||||
if noiseService.hasSession(with: senderID) {
|
if noiseService.hasEstablishedSession(with: senderID) {
|
||||||
SecureLogger.log("Received handshake init from \(senderID) but already have session/handshaking - ignoring duplicate", category: SecureLogger.noise, level: .warning)
|
// Determine who should be initiator based on peer ID comparison
|
||||||
return
|
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
|
// Check if we've completed version negotiation with this peer
|
||||||
@@ -2304,7 +2484,11 @@ class BluetoothMeshService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isPeerIDOurs(senderID) {
|
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)
|
handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2733,6 +2917,19 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
|
|||||||
connectedPeripherals.removeValue(forKey: peerID)
|
connectedPeripherals.removeValue(forKey: peerID)
|
||||||
peripheralCharacteristics.removeValue(forKey: peripheral)
|
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
|
// Only remove from active peers if it's not a temp ID
|
||||||
// Temp IDs shouldn't be in activePeers anyway
|
// Temp IDs shouldn't be in activePeers anyway
|
||||||
let (removed, _) = collectionsQueue.sync(flags: .barrier) {
|
let (removed, _) = collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -3245,20 +3442,48 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
private func sendPendingPrivateMessages(to peerID: String) {
|
private func sendPendingPrivateMessages(to peerID: String) {
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self,
|
guard let self = self else { return }
|
||||||
let pendingMessages = self.pendingPrivateMessages[peerID] 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
|
// 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
|
// Send each pending message
|
||||||
for (content, recipientNickname, messageID) in pendingMessages {
|
for (content, recipientNickname, messageID) in messages {
|
||||||
SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug)
|
// Check if this is a read receipt
|
||||||
// Use async to avoid blocking the queue
|
if content.hasPrefix("READ_RECEIPT:") {
|
||||||
DispatchQueue.global().async { [weak self] in
|
// Extract the original message ID
|
||||||
self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3276,16 +3501,43 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
SecureLogger.log("Already have established session with \(peerID)", category: SecureLogger.noise, level: .debug)
|
SecureLogger.log("Already have established session with \(peerID)", category: SecureLogger.noise, level: .debug)
|
||||||
// Clear any lingering handshake attempt time
|
// Clear any lingering handshake attempt time
|
||||||
handshakeAttemptTimes.removeValue(forKey: peerID)
|
handshakeAttemptTimes.removeValue(forKey: peerID)
|
||||||
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||||
|
|
||||||
|
// Force UI update since we have an existing session
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
(self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeers()
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've recently tried to handshake with this peer
|
// Check with coordinator if we should initiate
|
||||||
if let lastAttempt = handshakeAttemptTimes[peerID],
|
if !handshakeCoordinator.shouldInitiateHandshake(myPeerID: myPeerID, remotePeerID: peerID) {
|
||||||
Date().timeIntervalSince(lastAttempt) < handshakeTimeout {
|
SecureLogger.log("Coordinator says we should not initiate handshake with \(peerID)", category: SecureLogger.handshake, level: .debug)
|
||||||
SecureLogger.log("Skipping handshake with \(peerID) - too recent", category: SecureLogger.noise, level: .debug)
|
// 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
|
||||||
|
if let retryDelay = handshakeCoordinator.getRetryDelay(for: peerID), retryDelay > 0 {
|
||||||
|
SecureLogger.log("Waiting \(retryDelay)s before retrying handshake with \(peerID)", category: SecureLogger.handshake, level: .debug)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in
|
||||||
|
self?.initiateNoiseHandshake(with: peerID)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record that we're initiating
|
||||||
|
handshakeCoordinator.recordHandshakeInitiation(peerID: peerID)
|
||||||
handshakeAttemptTimes[peerID] = Date()
|
handshakeAttemptTimes[peerID] = Date()
|
||||||
|
|
||||||
|
|
||||||
@@ -3302,16 +3554,32 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: handshakeData,
|
payload: handshakeData,
|
||||||
signature: nil,
|
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
|
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
|
||||||
broadcastPacket(packet)
|
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 {
|
} catch NoiseSessionError.alreadyEstablished {
|
||||||
// Session already established, no need to handshake
|
// Session already established, no need to handshake
|
||||||
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||||
} catch {
|
} catch {
|
||||||
// Failed to initiate handshake silently
|
// Failed to initiate handshake
|
||||||
|
handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription)
|
||||||
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3319,9 +3587,32 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
// Use noiseService directly
|
// Use noiseService directly
|
||||||
SecureLogger.logHandshake("processing \(isInitiation ? "init" : "response")", peerID: peerID, success: true)
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an initiation, check if we should accept it
|
||||||
|
if isInitiation {
|
||||||
|
if !handshakeCoordinator.shouldAcceptHandshakeInitiation(myPeerID: myPeerID, remotePeerID: peerID) {
|
||||||
|
SecureLogger.log("Coordinator says we should not accept handshake from \(peerID)", category: SecureLogger.handshake, level: .debug)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Record that we're responding
|
||||||
|
handshakeCoordinator.recordHandshakeResponse(peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Process handshake message
|
// Process handshake message
|
||||||
if let response = try noiseService.processHandshakeMessage(from: peerID, message: 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
|
// Always send responses as handshake response type
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseHandshakeResp.rawValue,
|
type: MessageType.noiseHandshakeResp.rawValue,
|
||||||
@@ -3330,26 +3621,35 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: response,
|
payload: response,
|
||||||
signature: nil,
|
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
|
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
} else {
|
} 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
|
// 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))
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
// Unlock rotation now that handshake is complete
|
// Unlock rotation now that handshake is complete
|
||||||
unlockRotation()
|
unlockRotation()
|
||||||
|
|
||||||
// Session established successfully
|
// Session established successfully
|
||||||
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||||
|
|
||||||
// Clear handshake attempt time on success
|
// Clear handshake attempt time on success
|
||||||
handshakeAttemptTimes.removeValue(forKey: peerID)
|
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
|
// Send identity announcement to this specific peer
|
||||||
sendNoiseIdentityAnnounce(to: peerID)
|
sendNoiseIdentityAnnounce(to: peerID)
|
||||||
|
|
||||||
@@ -3372,9 +3672,18 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
} catch NoiseSessionError.alreadyEstablished {
|
} catch NoiseSessionError.alreadyEstablished {
|
||||||
// Session already established, ignore handshake
|
// Session already established, ignore handshake
|
||||||
SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info)
|
SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info)
|
||||||
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||||
} catch {
|
} catch {
|
||||||
// Handshake failed
|
// 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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3407,6 +3716,23 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
let decryptedData = try noiseService.decrypt(encryptedData, from: peerID)
|
let decryptedData = try noiseService.decrypt(encryptedData, from: peerID)
|
||||||
SecureLogger.log("Successfully decrypted message from \(peerID), decrypted size: \(decryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
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)
|
// Check if this is a special format message (type marker + payload)
|
||||||
if decryptedData.count > 1 {
|
if decryptedData.count > 1 {
|
||||||
let typeMarker = decryptedData[0]
|
let typeMarker = decryptedData[0]
|
||||||
@@ -3464,6 +3790,24 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
initiateNoiseHandshake(with: peerID)
|
initiateNoiseHandshake(with: peerID)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3481,6 +3825,21 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
return
|
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
|
// Try JSON first if it looks like JSON
|
||||||
let hello: VersionHello?
|
let hello: VersionHello?
|
||||||
if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character
|
if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character
|
||||||
@@ -3576,11 +3935,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
_ = self.activePeers.remove(peerID)
|
_ = self.activePeers.remove(peerID)
|
||||||
_ = self.peerNicknames.removeValue(forKey: peerID)
|
_ = self.peerNicknames.removeValue(forKey: peerID)
|
||||||
|
_ = self.lastHeardFromPeer.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
announcedPeers.remove(peerID)
|
announcedPeers.remove(peerID)
|
||||||
|
|
||||||
// Clean up any Noise session
|
// Clean up any Noise session
|
||||||
noiseService.removePeer(peerID)
|
cleanupPeerCryptoState(peerID)
|
||||||
|
|
||||||
// Notify delegate about incompatible peer disconnection
|
// Notify delegate about incompatible peer disconnection
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@@ -3724,11 +4084,34 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
// Send private message using Noise Protocol
|
// Send private message using Noise Protocol
|
||||||
private func sendPrivateMessageViaNoise(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
|
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
|
encryptionQueue.async { [weak self] in
|
||||||
if !noiseService.hasEstablishedSession(with: recipientPeerID) {
|
guard let self = self else { return }
|
||||||
SecureLogger.log("No Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
|
|
||||||
|
// 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
|
// Apply tie-breaker logic for handshake initiation
|
||||||
if myPeerID < recipientPeerID {
|
if myPeerID < recipientPeerID {
|
||||||
@@ -3746,7 +4129,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
self.pendingPrivateMessages[recipientPeerID] = []
|
self.pendingPrivateMessages[recipientPeerID] = []
|
||||||
}
|
}
|
||||||
self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString))
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -3756,11 +4140,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
// Check if we're already processing this message
|
// Check if we're already processing this message
|
||||||
let sendKey = "\(msgID)-\(recipientPeerID)"
|
let sendKey = "\(msgID)-\(recipientPeerID)"
|
||||||
let alreadySending = collectionsQueue.sync(flags: .barrier) {
|
let alreadySending = self.collectionsQueue.sync(flags: .barrier) {
|
||||||
if recentlySentMessages.contains(sendKey) {
|
if self.recentlySentMessages.contains(sendKey) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
recentlySentMessages.insert(sendKey)
|
self.recentlySentMessages.insert(sendKey)
|
||||||
// Clean up old entries after 10 seconds
|
// Clean up old entries after 10 seconds
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
|
||||||
self?.collectionsQueue.sync(flags: .barrier) {
|
self?.collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -3815,6 +4199,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID)
|
let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID)
|
||||||
SecureLogger.log("Successfully encrypted message, size: \(encryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
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
|
// Send as Noise encrypted message
|
||||||
let outerPacket = BitchatPacket(
|
let outerPacket = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
@@ -3832,5 +4219,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|||||||
// Failed to encrypt message
|
// Failed to encrypt message
|
||||||
SecureLogger.log("Failed to encrypt private message \(msgID) for \(recipientPeerID): \(error)", category: SecureLogger.encryption, level: .error)
|
SecureLogger.log("Failed to encrypt private message \(msgID) for \(recipientPeerID): \(error)", category: SecureLogger.encryption, level: .error)
|
||||||
}
|
}
|
||||||
|
} // End of encryptionQueue.async
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ class DeliveryTracker {
|
|||||||
private var sentAckIDs = Set<String>()
|
private var sentAckIDs = Set<String>()
|
||||||
|
|
||||||
// Timeout configuration
|
// Timeout configuration
|
||||||
private let privateMessageTimeout: TimeInterval = 30 // 30 seconds
|
private let privateMessageTimeout: TimeInterval = 120 // 2 minutes
|
||||||
private let roomMessageTimeout: TimeInterval = 60 // 1 minute
|
private let roomMessageTimeout: TimeInterval = 180 // 3 minutes
|
||||||
private let favoriteTimeout: TimeInterval = 300 // 5 minutes for favorites
|
private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites
|
||||||
|
|
||||||
// Retry configuration
|
// Retry configuration
|
||||||
private let maxRetries = 3
|
private let maxRetries = 3
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ enum EncryptionStatus: Equatable {
|
|||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return "lock.rotation"
|
return "lock.rotation"
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return "lock"
|
return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return "lock.shield"
|
return "lock.shield.fill" // Changed to filled version for consistency
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ class SecureLogger {
|
|||||||
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
|
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
|
||||||
static let session = OSLog(subsystem: subsystem, category: "session")
|
static let session = OSLog(subsystem: subsystem, category: "session")
|
||||||
static let security = OSLog(subsystem: subsystem, category: "security")
|
static let security = OSLog(subsystem: subsystem, category: "security")
|
||||||
|
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
||||||
|
|
||||||
|
// MARK: - Timestamp Formatter
|
||||||
|
|
||||||
|
private static let timestampFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Cached Regex Patterns
|
// MARK: - Cached Regex Patterns
|
||||||
|
|
||||||
@@ -135,7 +145,8 @@ class SecureLogger {
|
|||||||
/// Format location information for logging
|
/// Format location information for logging
|
||||||
private static func formatLocation(file: String, line: Int, function: String) -> String {
|
private static func formatLocation(file: String, line: Int, function: String) -> String {
|
||||||
let fileName = (file as NSString).lastPathComponent
|
let fileName = (file as NSString).lastPathComponent
|
||||||
return "[\(fileName):\(line) \(function)]"
|
let timestamp = timestampFormatter.string(from: Date())
|
||||||
|
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitize strings to remove potentially sensitive data
|
/// Sanitize strings to remove potentially sensitive data
|
||||||
|
|||||||
@@ -1172,21 +1172,33 @@ class ChatViewModel: ObservableObject {
|
|||||||
// MARK: - Noise Protocol Support
|
// MARK: - Noise Protocol Support
|
||||||
|
|
||||||
func updateEncryptionStatusForPeers() {
|
func updateEncryptionStatusForPeers() {
|
||||||
|
for peerID in connectedPeers {
|
||||||
|
updateEncryptionStatusForPeer(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEncryptionStatusForPeer(_ peerID: String) {
|
||||||
let noiseService = meshService.getNoiseService()
|
let noiseService = meshService.getNoiseService()
|
||||||
|
|
||||||
for peerID in connectedPeers {
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
// Check if fingerprint is verified using our persisted data
|
||||||
// Check if fingerprint is verified using our persisted data
|
if let fingerprint = getFingerprint(for: peerID),
|
||||||
if let fingerprint = noiseService.getPeerFingerprint(peerID),
|
verifiedFingerprints.contains(fingerprint) {
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
peerEncryptionStatus[peerID] = .noiseVerified
|
||||||
peerEncryptionStatus[peerID] = .noiseVerified
|
|
||||||
} else {
|
|
||||||
peerEncryptionStatus[peerID] = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Always use Noise - no legacy encryption
|
peerEncryptionStatus[peerID] = .noiseSecured
|
||||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
|
||||||
}
|
}
|
||||||
|
} 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
|
// This must be a pure function - no state mutations allowed
|
||||||
// to avoid SwiftUI update loops
|
// to avoid SwiftUI update loops
|
||||||
|
|
||||||
// Check if we have a fingerprint for this peer
|
let hasEstablished = meshService.getNoiseService().hasEstablishedSession(with: peerID)
|
||||||
if let fingerprint = getFingerprint(for: peerID) {
|
let hasSession = meshService.getNoiseService().hasSession(with: peerID)
|
||||||
// Check if this fingerprint is verified
|
let storedStatus = peerEncryptionStatus[peerID]
|
||||||
if verifiedFingerprints.contains(fingerprint) {
|
|
||||||
// Return verified if we have a Noise session
|
// First check if we have an established session
|
||||||
if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
if hasEstablished {
|
||||||
|
// We have encryption, now check if it's verified
|
||||||
|
if let fingerprint = getFingerprint(for: peerID) {
|
||||||
|
if verifiedFingerprints.contains(fingerprint) {
|
||||||
return .noiseVerified
|
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
|
// 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
|
// Update encryption status in appropriate places, not during view updates
|
||||||
private func updateEncryptionStatus(for peerID: String) {
|
private func updateEncryptionStatus(for peerID: String) {
|
||||||
if let fingerprint = getFingerprint(for: peerID) {
|
let noiseService = meshService.getNoiseService()
|
||||||
if verifiedFingerprints.contains(fingerprint) && meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
|
||||||
peerEncryptionStatus[peerID] = .noiseVerified
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
} else if meshService.getNoiseService().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
|
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
|
// Set up authentication callback
|
||||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
DispatchQueue.main.async {
|
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
|
// Update encryption status
|
||||||
if self?.verifiedFingerprints.contains(fingerprint) == true {
|
if self.verifiedFingerprints.contains(fingerprint) {
|
||||||
self?.peerEncryptionStatus[peerID] = .noiseVerified
|
self.peerEncryptionStatus[peerID] = .noiseVerified
|
||||||
|
SecureLogger.log("ChatViewModel: Setting encryption status to noiseVerified for \(peerID)", category: SecureLogger.security, level: .info)
|
||||||
} else {
|
} 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
|
// Remove ephemeral session from identity manager
|
||||||
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
|
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
|
// Resolve nickname using helper
|
||||||
let displayName = resolveNickname(for: peerID)
|
let displayName = resolveNickname(for: peerID)
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
if packet.type == 0x01 {
|
if packet.type == 0x01 {
|
||||||
plainCount += 1
|
plainCount += 1
|
||||||
} else if packet.type == 0x02 {
|
} else if packet.type == 0x02 {
|
||||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1) {
|
if let _ = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1) {
|
||||||
encryptedCount += 1
|
encryptedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +270,124 @@ final class IntegrationTests: XCTestCase {
|
|||||||
XCTAssertEqual(receivedMessages.count, totalMessages)
|
XCTAssertEqual(receivedMessages.count, totalMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPeerPresenceTrackingAndReconnection() {
|
||||||
|
// Test peer presence tracking and identity announcement on reconnection
|
||||||
|
connect("Alice", "Bob")
|
||||||
|
|
||||||
|
// Establish Noise sessions
|
||||||
|
do {
|
||||||
|
try establishNoiseSession("Alice", "Bob")
|
||||||
|
} catch {
|
||||||
|
XCTFail("Failed to establish Noise session: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Peer reconnection handled")
|
||||||
|
var bobReceivedIdentityAnnounce = false
|
||||||
|
var aliceReceivedIdentityAnnounce = false
|
||||||
|
|
||||||
|
// Track identity announcements
|
||||||
|
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == MessageType.noiseIdentityAnnounce.rawValue {
|
||||||
|
bobReceivedIdentityAnnounce = true
|
||||||
|
if aliceReceivedIdentityAnnounce {
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == MessageType.noiseIdentityAnnounce.rawValue {
|
||||||
|
aliceReceivedIdentityAnnounce = true
|
||||||
|
if bobReceivedIdentityAnnounce {
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate disconnect (out of range)
|
||||||
|
disconnect("Alice", "Bob")
|
||||||
|
|
||||||
|
// Wait to simulate extended disconnect period
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
// Reconnect
|
||||||
|
connect("Alice", "Bob")
|
||||||
|
|
||||||
|
// Both should receive identity announcements after reconnection
|
||||||
|
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||||
|
XCTAssertTrue(bobReceivedIdentityAnnounce)
|
||||||
|
XCTAssertTrue(aliceReceivedIdentityAnnounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptedMessageAfterPeerRestart() {
|
||||||
|
// Test that encrypted messages work after one peer restarts
|
||||||
|
connect("Alice", "Bob")
|
||||||
|
do {
|
||||||
|
try establishNoiseSession("Alice", "Bob")
|
||||||
|
} catch {
|
||||||
|
XCTFail("Failed to establish Noise session: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange an encrypted message
|
||||||
|
let firstExpectation = XCTestExpectation(description: "First message received")
|
||||||
|
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
|
if message.content == "Before restart" && message.isPrivate {
|
||||||
|
firstExpectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||||
|
wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
|
// Simulate Bob restart by recreating his Noise manager
|
||||||
|
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey)
|
||||||
|
|
||||||
|
// Bob should initiate new handshake
|
||||||
|
let handshakeExpectation = XCTestExpectation(description: "New handshake completed")
|
||||||
|
|
||||||
|
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == MessageType.noiseHandshakeInit.rawValue {
|
||||||
|
// Bob initiates new handshake after restart
|
||||||
|
do {
|
||||||
|
let response = try self.noiseManagers["Alice"]!.handleIncomingHandshake(
|
||||||
|
from: TestConstants.testPeerID2,
|
||||||
|
message: packet.payload
|
||||||
|
)
|
||||||
|
if let resp = response {
|
||||||
|
// Send response back to Bob
|
||||||
|
let responsePacket = TestHelpers.createTestPacket(
|
||||||
|
type: MessageType.noiseHandshakeResp.rawValue,
|
||||||
|
payload: resp
|
||||||
|
)
|
||||||
|
self.nodes["Bob"]!.simulateIncomingPacket(responsePacket)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTFail("Handshake handling failed: \(error)")
|
||||||
|
}
|
||||||
|
} else if packet.type == MessageType.noiseHandshakeResp.rawValue && packet.senderID.hexEncodedString() == TestConstants.testPeerID1 {
|
||||||
|
// Final handshake message (message 3 in XX pattern)
|
||||||
|
handshakeExpectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger handshake by trying to send a message
|
||||||
|
nodes["Bob"]!.sendPrivateMessage("After restart", to: TestConstants.testPeerID1, recipientNickname: "Alice")
|
||||||
|
|
||||||
|
wait(for: [handshakeExpectation], timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
|
// Now messages should work again
|
||||||
|
let secondExpectation = XCTestExpectation(description: "Message after restart received")
|
||||||
|
nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||||
|
if message.content == "After restart success" && message.isPrivate {
|
||||||
|
secondExpectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes["Bob"]!.sendPrivateMessage("After restart success", to: TestConstants.testPeerID1, recipientNickname: "Alice")
|
||||||
|
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
func testLargeScaleNetwork() {
|
func testLargeScaleNetwork() {
|
||||||
// Create larger network
|
// Create larger network
|
||||||
for i in 5...10 {
|
for i in 5...10 {
|
||||||
|
|||||||
@@ -295,6 +295,165 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
XCTAssertEqual(decrypted, plaintext)
|
XCTAssertEqual(decrypted, plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Recovery Tests
|
||||||
|
|
||||||
|
func testPeerRestartDetection() throws {
|
||||||
|
// Establish initial sessions
|
||||||
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey)
|
||||||
|
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
// Exchange some messages to establish nonce state
|
||||||
|
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||||
|
_ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1)
|
||||||
|
|
||||||
|
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1)
|
||||||
|
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2)
|
||||||
|
|
||||||
|
// Simulate Bob restart by creating new manager with same key
|
||||||
|
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey)
|
||||||
|
|
||||||
|
// Bob initiates new handshake after restart
|
||||||
|
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1)
|
||||||
|
|
||||||
|
// Alice should accept the new handshake (clearing old session)
|
||||||
|
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1)
|
||||||
|
XCTAssertNotNil(newHandshake2)
|
||||||
|
|
||||||
|
// Complete the new handshake
|
||||||
|
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!)
|
||||||
|
XCTAssertNotNil(newHandshake3)
|
||||||
|
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake3!)
|
||||||
|
|
||||||
|
// Should be able to exchange messages with new sessions
|
||||||
|
let testMessage = "After restart".data(using: .utf8)!
|
||||||
|
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1)
|
||||||
|
let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2)
|
||||||
|
XCTAssertEqual(decrypted, testMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonceDesynchronizationRecovery() throws {
|
||||||
|
// Create two sessions
|
||||||
|
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, localStaticKey: aliceKey)
|
||||||
|
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, localStaticKey: bobKey)
|
||||||
|
|
||||||
|
// Establish sessions
|
||||||
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
|
|
||||||
|
// Exchange messages to advance nonces
|
||||||
|
for i in 0..<5 {
|
||||||
|
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
|
||||||
|
_ = try bobSession.decrypt(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate desynchronization by encrypting but not decrypting
|
||||||
|
for i in 0..<3 {
|
||||||
|
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next message from Alice should fail to decrypt (nonce mismatch)
|
||||||
|
let desyncMessage = try aliceSession.encrypt("This will fail".data(using: .utf8)!)
|
||||||
|
XCTAssertThrowsError(try bobSession.decrypt(desyncMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConcurrentEncryption() throws {
|
||||||
|
// Test thread safety of encryption operations
|
||||||
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey)
|
||||||
|
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
let messageCount = 100
|
||||||
|
let expectation = XCTestExpectation(description: "All messages encrypted and decrypted")
|
||||||
|
expectation.expectedFulfillmentCount = messageCount * 2
|
||||||
|
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var encryptedMessages: [Int: Data] = [:]
|
||||||
|
let encryptionQueue = DispatchQueue(label: "test.encryption", attributes: .concurrent)
|
||||||
|
let lock = NSLock()
|
||||||
|
|
||||||
|
// Encrypt messages concurrently
|
||||||
|
for i in 0..<messageCount {
|
||||||
|
group.enter()
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
do {
|
||||||
|
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
||||||
|
let encrypted = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2)
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
encryptedMessages[i] = encrypted
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
expectation.fulfill()
|
||||||
|
} catch {
|
||||||
|
XCTFail("Encryption failed: \(error)")
|
||||||
|
}
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all encryptions to complete
|
||||||
|
group.wait()
|
||||||
|
|
||||||
|
// Decrypt messages in order
|
||||||
|
for i in 0..<messageCount {
|
||||||
|
encryptionQueue.async {
|
||||||
|
do {
|
||||||
|
lock.lock()
|
||||||
|
guard let encrypted = encryptedMessages[i] else {
|
||||||
|
lock.unlock()
|
||||||
|
XCTFail("Missing encrypted message \(i)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
|
||||||
|
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
||||||
|
XCTAssertEqual(decrypted, expected)
|
||||||
|
expectation.fulfill()
|
||||||
|
} catch {
|
||||||
|
XCTFail("Decryption failed for message \(i): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: 10.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSessionStaleDetection() throws {
|
||||||
|
// Test that sessions are properly marked as stale
|
||||||
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey)
|
||||||
|
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
// Get the session and check it needs renegotiation based on age
|
||||||
|
let sessions = aliceManager.getSessionsNeedingRekey()
|
||||||
|
|
||||||
|
// New session should not need rekey
|
||||||
|
XCTAssertTrue(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHandshakeAfterDecryptionFailure() throws {
|
||||||
|
// Test that handshake is properly initiated after decryption failure
|
||||||
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey)
|
||||||
|
|
||||||
|
// Establish sessions
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
// Create a corrupted message
|
||||||
|
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||||
|
encrypted[10] ^= 0xFF // Corrupt the data
|
||||||
|
|
||||||
|
// Decryption should fail
|
||||||
|
XCTAssertThrowsError(try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1))
|
||||||
|
|
||||||
|
// Bob should still have the session (it's not removed on single failure)
|
||||||
|
XCTAssertNotNil(bobManager.getSession(for: TestConstants.testPeerID1))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Performance Tests
|
// MARK: - Performance Tests
|
||||||
|
|
||||||
func testHandshakePerformance() throws {
|
func testHandshakePerformance() throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user