mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:45:19 +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 */; };
|
||||
04636BE82E30BEC600FBCFA8 /* 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 */; };
|
||||
04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -226,6 +229,7 @@
|
||||
04B6BA442E2035530090FE39 /* Noise */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */,
|
||||
04B6BA412E2035530090FE39 /* NoiseProtocol.swift */,
|
||||
04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */,
|
||||
04B6BA432E2035530090FE39 /* NoiseSession.swift */,
|
||||
@@ -563,6 +567,7 @@
|
||||
04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */,
|
||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
|
||||
04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
||||
04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */,
|
||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
||||
04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */,
|
||||
@@ -595,6 +600,7 @@
|
||||
1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */,
|
||||
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
|
||||
04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
||||
04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */,
|
||||
61C81ED5F679D5E973EE0C07 /* NotificationService.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
|
||||
}
|
||||
|
||||
SecureLogger.log("NoiseHandshake[\(role)]: Writing message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
var messageBuffer = Data()
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
@@ -390,6 +391,7 @@ class NoiseHandshakeState {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
SecureLogger.log("NoiseHandshake[\(role)]: Reading message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
var buffer = message
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
@@ -92,6 +92,7 @@ class NoiseSession {
|
||||
|
||||
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
||||
return try sessionQueue.sync(flags: .barrier) {
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Initialize handshake state if needed (for responders)
|
||||
if state == .uninitialized && role == .responder {
|
||||
@@ -102,6 +103,7 @@ class NoiseSession {
|
||||
remoteStaticKey: nil
|
||||
)
|
||||
state = .handshaking
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
|
||||
guard case .handshaking = state, let handshake = handshakeState else {
|
||||
@@ -110,6 +112,7 @@ class NoiseSession {
|
||||
|
||||
// Process incoming message
|
||||
_ = try handshake.readMessage(message)
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Check if handshake is complete
|
||||
if handshake.isHandshakeComplete() {
|
||||
@@ -127,6 +130,7 @@ class NoiseSession {
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .info)
|
||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||
|
||||
return nil
|
||||
@@ -134,6 +138,7 @@ class NoiseSession {
|
||||
// Generate response
|
||||
let response = try handshake.writeMessage()
|
||||
sentHandshakeMessages.append(response)
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Check if handshake is complete after writing
|
||||
if handshake.isHandshakeComplete() {
|
||||
@@ -151,6 +156,7 @@ class NoiseSession {
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .info)
|
||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||
}
|
||||
|
||||
@@ -162,7 +168,7 @@ class NoiseSession {
|
||||
// MARK: - Transport
|
||||
|
||||
func encrypt(_ plaintext: Data) throws -> Data {
|
||||
return try sessionQueue.sync {
|
||||
return try sessionQueue.sync(flags: .barrier) {
|
||||
guard case .established = state, let cipher = sendCipher else {
|
||||
throw NoiseSessionError.notEstablished
|
||||
}
|
||||
@@ -172,7 +178,7 @@ class NoiseSession {
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
return try sessionQueue.sync {
|
||||
return try sessionQueue.sync(flags: .barrier) {
|
||||
guard case .established = state, let cipher = receiveCipher else {
|
||||
throw NoiseSessionError.notEstablished
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ class BluetoothMeshService: NSObject {
|
||||
private var discoveredPeripherals: [CBPeripheral] = []
|
||||
private var connectedPeripherals: [String: CBPeripheral] = [:]
|
||||
private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:]
|
||||
private var lastConnectionTime: [String: Date] = [:] // Track when peers last connected
|
||||
private var lastSuccessfulMessageTime: [String: Date] = [:] // Track last successful message exchange
|
||||
private var lastHeardFromPeer: [String: Date] = [:] // Track last time we received ANY packet from peer
|
||||
private var characteristic: CBMutableCharacteristic?
|
||||
private var subscribedCentrals: [CBCentral] = []
|
||||
// Thread-safe collections using concurrent queues
|
||||
@@ -55,6 +58,10 @@ class BluetoothMeshService: NSObject {
|
||||
private var peerRSSI: [String: NSNumber] = [:] // Track RSSI values for peers
|
||||
private var peripheralRSSI: [String: NSNumber] = [:] // Track RSSI by peripheral ID during discovery
|
||||
|
||||
// Per-peer encryption queues to prevent nonce desynchronization
|
||||
private var peerEncryptionQueues: [String: DispatchQueue] = [:]
|
||||
private let encryptionQueuesLock = NSLock()
|
||||
|
||||
// MARK: - Peer Identity Rotation
|
||||
// Mappings between ephemeral peer IDs and permanent fingerprints
|
||||
private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint
|
||||
@@ -68,6 +75,7 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
private let noiseService = NoiseEncryptionService()
|
||||
private let handshakeCoordinator = NoiseHandshakeCoordinator()
|
||||
|
||||
// Protocol version negotiation state
|
||||
private var versionNegotiationState: [String: VersionNegotiationState] = [:]
|
||||
@@ -332,6 +340,12 @@ class BluetoothMeshService: NSObject {
|
||||
self.peerRSSI.removeValue(forKey: existingPeerID)
|
||||
self.peerRSSI[newPeerID] = rssi
|
||||
}
|
||||
|
||||
// Transfer lastHeardFromPeer tracking
|
||||
if let lastHeard = self.lastHeardFromPeer[existingPeerID] {
|
||||
self.lastHeardFromPeer.removeValue(forKey: existingPeerID)
|
||||
self.lastHeardFromPeer[newPeerID] = lastHeard
|
||||
}
|
||||
}
|
||||
|
||||
// Add new mapping
|
||||
@@ -344,10 +358,21 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
// Notify about the change if it's a rotation
|
||||
if let oldID = oldPeerID {
|
||||
// Migrate Noise session to new peer ID
|
||||
self.noiseService.migratePeerSession(from: oldID, to: newPeerID, fingerprint: fingerprint)
|
||||
// Clear the old session instead of migrating it
|
||||
// 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)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
scheduleNextRotation()
|
||||
|
||||
@@ -529,6 +573,9 @@ class BluetoothMeshService: NSObject {
|
||||
// Register with ChatViewModel for verification tracking
|
||||
DispatchQueue.main.async {
|
||||
(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
|
||||
peerLastSeenTimestamps.removeAll()
|
||||
|
||||
// Clear all encryption queues
|
||||
encryptionQueuesLock.lock()
|
||||
peerEncryptionQueues.removeAll()
|
||||
encryptionQueuesLock.unlock()
|
||||
|
||||
// Clear peer tracking
|
||||
lastHeardFromPeer.removeAll()
|
||||
}
|
||||
|
||||
func startServices() {
|
||||
@@ -890,7 +945,10 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
|
||||
func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
// Use per-peer encryption queue to prevent nonce desynchronization
|
||||
let encryptionQueue = getEncryptionQueue(for: recipientID)
|
||||
|
||||
encryptionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Encode the ACK
|
||||
@@ -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) {
|
||||
messageQueue.async { [weak self] in
|
||||
// Use per-peer encryption queue to prevent nonce desynchronization
|
||||
let encryptionQueue = getEncryptionQueue(for: recipientID)
|
||||
|
||||
encryptionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Encode the receipt
|
||||
@@ -990,33 +1078,38 @@ class BluetoothMeshService: NSObject {
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
SecureLogger.log("Sending encrypted read receipt for message \(receipt.originalMessageID) to \(recipientID)", category: SecureLogger.noise, level: .info)
|
||||
self.broadcastPacket(outerPacket)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption)
|
||||
}
|
||||
} else {
|
||||
// Fall back to legacy encryption
|
||||
let encryptedPayload: Data
|
||||
do {
|
||||
encryptedPayload = try self.noiseService.encrypt(receiptData, for: recipientID)
|
||||
} catch {
|
||||
return
|
||||
// No session - initiate handshake and queue the read receipt
|
||||
SecureLogger.log("No Noise session with \(recipientID) for read receipt, initiating handshake", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Initiate handshake regardless of our role if we need to send data
|
||||
self.initiateNoiseHandshake(with: recipientID)
|
||||
|
||||
// Queue the read receipt as a pending message
|
||||
// Create a synthetic message ID for the read receipt
|
||||
let readReceiptMessageID = "READ_RECEIPT_\(receipt.originalMessageID)"
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if self.pendingPrivateMessages[recipientID] == nil {
|
||||
self.pendingPrivateMessages[recipientID] = []
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
// Store the read receipt data as a pending "message"
|
||||
self.pendingPrivateMessages[recipientID]?.append((
|
||||
content: "READ_RECEIPT:\(receipt.originalMessageID)",
|
||||
recipientNickname: receipt.readerNickname,
|
||||
messageID: readReceiptMessageID
|
||||
))
|
||||
|
||||
// Send immediately without delay
|
||||
self.broadcastPacket(packet)
|
||||
let count = self.pendingPrivateMessages[recipientID]?.count ?? 0
|
||||
SecureLogger.log("Queued read receipt for \(recipientID), pending messages: \(count)", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1129,11 +1222,29 @@ class BluetoothMeshService: NSObject {
|
||||
lastNetworkNotificationTime = nil
|
||||
processedMessages.removeAll()
|
||||
incomingFragments.removeAll()
|
||||
|
||||
// Clear all encryption queues
|
||||
encryptionQueuesLock.lock()
|
||||
peerEncryptionQueues.removeAll()
|
||||
encryptionQueuesLock.unlock()
|
||||
fragmentMetadata.removeAll()
|
||||
|
||||
// Clear peer tracking
|
||||
lastHeardFromPeer.removeAll()
|
||||
|
||||
// Clear persistent identity
|
||||
noiseService.clearPersistentIdentity()
|
||||
|
||||
// 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] {
|
||||
@@ -1218,6 +1329,7 @@ class BluetoothMeshService: NSObject {
|
||||
announcedPeers.remove(peerID)
|
||||
announcedToPeers.remove(peerID)
|
||||
peerNicknames.removeValue(forKey: peerID)
|
||||
lastHeardFromPeer.removeValue(forKey: peerID)
|
||||
|
||||
actuallyRemoved.append(peerID)
|
||||
// Removed stale peer
|
||||
@@ -1547,6 +1659,30 @@ class BluetoothMeshService: NSObject {
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Track that we heard from this peer
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
if !senderID.isEmpty && senderID != self.myPeerID {
|
||||
// Check if this is a reconnection after a long silence
|
||||
let wasReconnection: Bool
|
||||
if let lastHeard = self.lastHeardFromPeer[senderID] {
|
||||
let timeSinceLastHeard = Date().timeIntervalSince(lastHeard)
|
||||
wasReconnection = timeSinceLastHeard > 30.0
|
||||
} else {
|
||||
// First time hearing from this peer
|
||||
wasReconnection = true
|
||||
}
|
||||
|
||||
self.lastHeardFromPeer[senderID] = Date()
|
||||
|
||||
// If this is a reconnection, send our identity announcement
|
||||
if wasReconnection && packet.type != MessageType.noiseIdentityAnnounce.rawValue {
|
||||
SecureLogger.log("Detected reconnection from \(senderID) after silence, sending identity announcement", category: SecureLogger.noise, level: .info)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.sendNoiseIdentityAnnounce(to: senderID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Log specific Noise packet types
|
||||
|
||||
@@ -1560,7 +1696,6 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
|
||||
// Update last seen timestamp for this peer
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
if senderID != "unknown" && senderID != self.myPeerID {
|
||||
peerLastSeenTimestamps.set(senderID, value: Date())
|
||||
}
|
||||
@@ -1846,6 +1981,9 @@ class BluetoothMeshService: NSObject {
|
||||
self.announcedPeers.remove(stalePeerID)
|
||||
self.announcedToPeers.remove(stalePeerID)
|
||||
|
||||
// Clear tracking data
|
||||
self.lastHeardFromPeer.removeValue(forKey: stalePeerID)
|
||||
|
||||
// Disconnect any peripherals associated with stale ID
|
||||
if let peripheral = self.connectedPeripherals[stalePeerID] {
|
||||
self.intentionalDisconnects.insert(peripheral.identifier.uuidString)
|
||||
@@ -2128,14 +2266,17 @@ class BluetoothMeshService: NSObject {
|
||||
isPeerIDOurs(recipientIDData.hexEncodedString()) {
|
||||
// This read receipt is for us
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
SecureLogger.log("Received read receipt from \(senderID)", category: SecureLogger.session, level: .info)
|
||||
// Check if payload is already decrypted (came through Noise)
|
||||
if let receipt = ReadReceipt.fromBinaryData(packet.payload) {
|
||||
// Already decrypted - process directly
|
||||
SecureLogger.log("Processing read receipt for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info)
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveReadReceipt(receipt)
|
||||
}
|
||||
} else if let receipt = ReadReceipt.decode(from: packet.payload) {
|
||||
// Fallback to JSON for backward compatibility
|
||||
SecureLogger.log("Processing read receipt (JSON) for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info)
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveReadReceipt(receipt)
|
||||
}
|
||||
@@ -2231,7 +2372,19 @@ class BluetoothMeshService: NSObject {
|
||||
// Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes
|
||||
// Only the peer with the "lower" ID initiates
|
||||
if myPeerID < announcement.peerID {
|
||||
// Add small delay on fresh startup to let connections stabilize
|
||||
let lastConnection = lastConnectionTime[announcement.peerID] ?? Date.distantPast
|
||||
let timeSinceConnection = Date().timeIntervalSince(lastConnection)
|
||||
|
||||
if timeSinceConnection > 60.0 { // Fresh connection
|
||||
// Delay handshake initiation slightly for connection stability
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.initiateNoiseHandshake(with: announcement.peerID)
|
||||
}
|
||||
} else {
|
||||
// Quick reconnection, initiate immediately
|
||||
initiateNoiseHandshake(with: announcement.peerID)
|
||||
}
|
||||
} else {
|
||||
// Send our identity back so they know we're ready
|
||||
sendNoiseIdentityAnnounce(to: announcement.peerID)
|
||||
@@ -2265,10 +2418,37 @@ class BluetoothMeshService: NSObject {
|
||||
return
|
||||
}
|
||||
if !isPeerIDOurs(senderID) {
|
||||
// Check if we already have a session (established or handshaking)
|
||||
if noiseService.hasSession(with: senderID) {
|
||||
SecureLogger.log("Received handshake init from \(senderID) but already have session/handshaking - ignoring duplicate", category: SecureLogger.noise, level: .warning)
|
||||
return
|
||||
// Check if we already have an established session
|
||||
if noiseService.hasEstablishedSession(with: senderID) {
|
||||
// Determine who should be initiator based on peer ID comparison
|
||||
let shouldBeInitiator = myPeerID < senderID
|
||||
|
||||
if shouldBeInitiator {
|
||||
// We should be initiator but peer is initiating - likely they had a session failure
|
||||
SecureLogger.log("Received handshake init from \(senderID) who should be responder - likely session mismatch, clearing and accepting", category: SecureLogger.noise, level: .warning)
|
||||
cleanupPeerCryptoState(senderID)
|
||||
} else {
|
||||
// Check if we've heard from this peer recently
|
||||
let lastHeard = lastHeardFromPeer[senderID] ?? Date.distantPast
|
||||
let timeSinceLastHeard = Date().timeIntervalSince(lastHeard)
|
||||
|
||||
// If we haven't heard from the peer in 30 seconds, they likely disconnected and reconnected
|
||||
if timeSinceLastHeard > 30.0 {
|
||||
SecureLogger.log("Received handshake init from \(senderID) after \(Int(timeSinceLastHeard))s silence - likely reconnected, clearing old session", category: SecureLogger.noise, level: .info)
|
||||
cleanupPeerCryptoState(senderID)
|
||||
} else {
|
||||
// We've heard from them recently but they're initiating a new handshake
|
||||
// This likely means they restarted and lost their session
|
||||
SecureLogger.log("Received handshake init from \(senderID) despite recent communication - peer likely restarted, clearing old session", category: SecureLogger.noise, level: .info)
|
||||
cleanupPeerCryptoState(senderID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a handshaking session, reset it to allow new handshake
|
||||
if noiseService.hasSession(with: senderID) && !noiseService.hasEstablishedSession(with: senderID) {
|
||||
SecureLogger.log("Received handshake init from \(senderID) while already handshaking - resetting to allow new handshake", category: SecureLogger.noise, level: .info)
|
||||
cleanupPeerCryptoState(senderID)
|
||||
}
|
||||
|
||||
// Check if we've completed version negotiation with this peer
|
||||
@@ -2304,7 +2484,11 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
|
||||
if !isPeerIDOurs(senderID) {
|
||||
SecureLogger.log("Processing handshake response from \(senderID)", category: SecureLogger.noise, level: .info)
|
||||
// Check our current handshake state
|
||||
let currentState = handshakeCoordinator.getHandshakeState(for: senderID)
|
||||
SecureLogger.log("Processing handshake response from \(senderID), current state: \(currentState)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Process the response - this could be message 2 or message 3 in the XX pattern
|
||||
handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false)
|
||||
}
|
||||
|
||||
@@ -2733,6 +2917,19 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||
connectedPeripherals.removeValue(forKey: peerID)
|
||||
peripheralCharacteristics.removeValue(forKey: peripheral)
|
||||
|
||||
// Don't clear Noise session on disconnect - sessions should survive disconnects
|
||||
// The Noise protocol is designed to maintain sessions across network interruptions
|
||||
// Only clear sessions on authentication failure
|
||||
if peerID.count == 16 { // Real peer ID
|
||||
// Clear connection time and last heard tracking on disconnect to properly detect stale sessions
|
||||
lastConnectionTime.removeValue(forKey: peerID)
|
||||
lastHeardFromPeer.removeValue(forKey: peerID)
|
||||
// Keep lastSuccessfulMessageTime to validate session on reconnect
|
||||
let lastSuccess = lastSuccessfulMessageTime[peerID] ?? Date.distantPast
|
||||
let sessionAge = Date().timeIntervalSince(lastSuccess)
|
||||
SecureLogger.log("Peer disconnected: \(peerID), keeping Noise session (age: \(Int(sessionAge))s)", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
|
||||
// Only remove from active peers if it's not a temp ID
|
||||
// Temp IDs shouldn't be in activePeers anyway
|
||||
let (removed, _) = collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -3245,16 +3442,43 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
|
||||
private func sendPendingPrivateMessages(to peerID: String) {
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self,
|
||||
let pendingMessages = self.pendingPrivateMessages[peerID] else { return }
|
||||
guard let self = self else { return }
|
||||
|
||||
SecureLogger.log("Sending \(pendingMessages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info)
|
||||
// Get pending messages with proper queue synchronization
|
||||
let pendingMessages = self.collectionsQueue.sync {
|
||||
return self.pendingPrivateMessages[peerID]
|
||||
}
|
||||
|
||||
guard let messages = pendingMessages else { return }
|
||||
|
||||
SecureLogger.log("Sending \(messages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info)
|
||||
|
||||
// Clear pending messages for this peer
|
||||
self.pendingPrivateMessages.removeValue(forKey: peerID)
|
||||
self.collectionsQueue.sync(flags: .barrier) {
|
||||
_ = self.pendingPrivateMessages.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Send each pending message
|
||||
for (content, recipientNickname, messageID) in pendingMessages {
|
||||
for (content, recipientNickname, messageID) in messages {
|
||||
// Check if this is a read receipt
|
||||
if content.hasPrefix("READ_RECEIPT:") {
|
||||
// Extract the original message ID
|
||||
let originalMessageID = String(content.dropFirst("READ_RECEIPT:".count))
|
||||
SecureLogger.log("Sending queued read receipt for message \(originalMessageID) to \(peerID)", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Create and send the actual read receipt
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: originalMessageID,
|
||||
readerID: self.myPeerID,
|
||||
readerNickname: recipientNickname // This is actually the reader's nickname
|
||||
)
|
||||
|
||||
// Send the read receipt using the normal method
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
} else {
|
||||
// Regular message
|
||||
SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug)
|
||||
// Use async to avoid blocking the queue
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
@@ -3263,6 +3487,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Protocol Support
|
||||
|
||||
@@ -3276,16 +3501,43 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
SecureLogger.log("Already have established session with \(peerID)", category: SecureLogger.noise, level: .debug)
|
||||
// Clear any lingering handshake attempt time
|
||||
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
|
||||
}
|
||||
|
||||
// Check if we've recently tried to handshake with this peer
|
||||
if let lastAttempt = handshakeAttemptTimes[peerID],
|
||||
Date().timeIntervalSince(lastAttempt) < handshakeTimeout {
|
||||
SecureLogger.log("Skipping handshake with \(peerID) - too recent", category: SecureLogger.noise, level: .debug)
|
||||
// Check with coordinator if we should initiate
|
||||
if !handshakeCoordinator.shouldInitiateHandshake(myPeerID: myPeerID, remotePeerID: peerID) {
|
||||
SecureLogger.log("Coordinator says we should not initiate handshake with \(peerID)", category: SecureLogger.handshake, level: .debug)
|
||||
// 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
|
||||
}
|
||||
|
||||
// Record that we're initiating
|
||||
handshakeCoordinator.recordHandshakeInitiation(peerID: peerID)
|
||||
handshakeAttemptTimes[peerID] = Date()
|
||||
|
||||
|
||||
@@ -3302,16 +3554,32 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: handshakeData,
|
||||
signature: nil,
|
||||
ttl: 3 // Moderate TTL for handshakes
|
||||
ttl: 6 // Increased TTL for better delivery on startup
|
||||
)
|
||||
|
||||
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
|
||||
broadcastPacket(packet)
|
||||
|
||||
// Schedule a retry check after 5 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Check if handshake completed
|
||||
if !self.noiseService.hasEstablishedSession(with: peerID) {
|
||||
let state = self.handshakeCoordinator.getHandshakeState(for: peerID)
|
||||
if case .initiating = state {
|
||||
SecureLogger.log("Handshake with \(peerID) not completed after 5s, will retry", category: SecureLogger.handshake, level: .warning)
|
||||
// The handshake coordinator will handle retry logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch NoiseSessionError.alreadyEstablished {
|
||||
// Session already established, no need to handshake
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
} 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
|
||||
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 {
|
||||
// Process handshake message
|
||||
if let response = try noiseService.processHandshakeMessage(from: peerID, message: message) {
|
||||
SecureLogger.log("Handshake processing returned response of size \(response.count), sending back to \(peerID)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Always send responses as handshake response type
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseHandshakeResp.rawValue,
|
||||
@@ -3330,26 +3621,35 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: response,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
ttl: 6 // Increased TTL for better delivery on startup
|
||||
)
|
||||
|
||||
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
|
||||
broadcastPacket(packet)
|
||||
} else {
|
||||
SecureLogger.log("No response needed from processHandshakeMessage", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.log("No response needed from processHandshakeMessage (isInitiation: \(isInitiation))", category: SecureLogger.noise, level: .debug)
|
||||
}
|
||||
|
||||
// Check if handshake is complete
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
let sessionEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
let newState = handshakeCoordinator.getHandshakeState(for: peerID)
|
||||
SecureLogger.log("After processing handshake message - sessionEstablished: \(sessionEstablished), newState: \(newState)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
if sessionEstablished {
|
||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||
// Unlock rotation now that handshake is complete
|
||||
unlockRotation()
|
||||
|
||||
// Session established successfully
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
|
||||
// Clear handshake attempt time on success
|
||||
handshakeAttemptTimes.removeValue(forKey: peerID)
|
||||
|
||||
// Initialize last successful message time
|
||||
lastSuccessfulMessageTime[peerID] = Date()
|
||||
SecureLogger.log("Initialized lastSuccessfulMessageTime for \(peerID)", category: SecureLogger.noise, level: .debug)
|
||||
|
||||
// Send identity announcement to this specific peer
|
||||
sendNoiseIdentityAnnounce(to: peerID)
|
||||
|
||||
@@ -3372,9 +3672,18 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
} catch NoiseSessionError.alreadyEstablished {
|
||||
// Session already established, ignore handshake
|
||||
SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info)
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
} catch {
|
||||
// Handshake failed
|
||||
handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription)
|
||||
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
SecureLogger.log("Handshake failed with \(peerID): \(error)", category: SecureLogger.noise, level: .error)
|
||||
|
||||
// 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)
|
||||
SecureLogger.log("Successfully decrypted message from \(peerID), decrypted size: \(decryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
||||
|
||||
// Update last successful message time
|
||||
lastSuccessfulMessageTime[peerID] = Date()
|
||||
|
||||
// If we can decrypt messages from this peer, they should be in activePeers
|
||||
let wasAdded = collectionsQueue.sync(flags: .barrier) {
|
||||
if !self.activePeers.contains(peerID) {
|
||||
SecureLogger.log("Adding \(peerID) to activePeers after successful decryption", category: SecureLogger.noise, level: .info)
|
||||
return self.activePeers.insert(peerID).inserted
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if wasAdded {
|
||||
// Notify about peer list update
|
||||
self.notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
// Check if this is a special format message (type marker + payload)
|
||||
if decryptedData.count > 1 {
|
||||
let typeMarker = decryptedData[0]
|
||||
@@ -3464,6 +3790,24 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
} else {
|
||||
SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning)
|
||||
|
||||
// Session is corrupted - clear it and re-initiate handshake
|
||||
cleanupPeerCryptoState(peerID)
|
||||
|
||||
// Send identity announcement to prompt peer to initiate handshake if needed
|
||||
sendNoiseIdentityAnnounce(to: peerID)
|
||||
|
||||
// Update UI to show encryption is broken
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let chatVM = self?.delegate as? ChatViewModel {
|
||||
chatVM.updateEncryptionStatusForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate fresh handshake after a short delay to avoid collision
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.initiateNoiseHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3481,6 +3825,21 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this peer is reconnecting after disconnect
|
||||
if let lastConnected = lastConnectionTime[peerID] {
|
||||
let timeSinceLastConnection = Date().timeIntervalSince(lastConnected)
|
||||
if timeSinceLastConnection > 5.0 { // More than 5 seconds since last connection
|
||||
// Clear any stale Noise session
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("Peer \(peerID) reconnecting after \(Int(timeSinceLastConnection))s - clearing stale session", category: SecureLogger.noise, level: .info)
|
||||
cleanupPeerCryptoState(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last connection time
|
||||
lastConnectionTime[peerID] = Date()
|
||||
|
||||
// Try JSON first if it looks like JSON
|
||||
let hello: VersionHello?
|
||||
if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character
|
||||
@@ -3576,11 +3935,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
_ = self.activePeers.remove(peerID)
|
||||
_ = self.peerNicknames.removeValue(forKey: peerID)
|
||||
_ = self.lastHeardFromPeer.removeValue(forKey: peerID)
|
||||
}
|
||||
announcedPeers.remove(peerID)
|
||||
|
||||
// Clean up any Noise session
|
||||
noiseService.removePeer(peerID)
|
||||
cleanupPeerCryptoState(peerID)
|
||||
|
||||
// Notify delegate about incompatible peer disconnection
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -3724,11 +4084,34 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
|
||||
// Send private message using Noise Protocol
|
||||
private func sendPrivateMessageViaNoise(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
|
||||
// Use per-peer encryption queue to prevent nonce desynchronization
|
||||
let encryptionQueue = getEncryptionQueue(for: recipientPeerID)
|
||||
|
||||
encryptionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Use noiseService directly
|
||||
|
||||
// Check if we have a Noise session with this peer
|
||||
if !noiseService.hasEstablishedSession(with: recipientPeerID) {
|
||||
SecureLogger.log("No Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
|
||||
let hasSession = self.noiseService.hasEstablishedSession(with: recipientPeerID)
|
||||
|
||||
// Check if session is stale (no successful communication for a while)
|
||||
var sessionIsStale = false
|
||||
if hasSession {
|
||||
let lastSuccess = lastSuccessfulMessageTime[recipientPeerID] ?? Date.distantPast
|
||||
let sessionAge = Date().timeIntervalSince(lastSuccess)
|
||||
if sessionAge > 600.0 { // More than 10 minutes since last successful message
|
||||
sessionIsStale = true
|
||||
SecureLogger.log("Session with \(recipientPeerID) is stale (last success: \(Int(sessionAge))s ago), will re-establish", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSession || sessionIsStale {
|
||||
if sessionIsStale {
|
||||
// Clear stale session first
|
||||
cleanupPeerCryptoState(recipientPeerID)
|
||||
}
|
||||
SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Apply tie-breaker logic for handshake initiation
|
||||
if myPeerID < recipientPeerID {
|
||||
@@ -3746,7 +4129,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
self.pendingPrivateMessages[recipientPeerID] = []
|
||||
}
|
||||
self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString))
|
||||
SecureLogger.log("Queued private message for \(recipientPeerID), \(self.pendingPrivateMessages[recipientPeerID]?.count ?? 0) messages pending", category: SecureLogger.noise, level: .info)
|
||||
let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0
|
||||
SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -3756,11 +4140,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
|
||||
// Check if we're already processing this message
|
||||
let sendKey = "\(msgID)-\(recipientPeerID)"
|
||||
let alreadySending = collectionsQueue.sync(flags: .barrier) {
|
||||
if recentlySentMessages.contains(sendKey) {
|
||||
let alreadySending = self.collectionsQueue.sync(flags: .barrier) {
|
||||
if self.recentlySentMessages.contains(sendKey) {
|
||||
return true
|
||||
}
|
||||
recentlySentMessages.insert(sendKey)
|
||||
self.recentlySentMessages.insert(sendKey)
|
||||
// Clean up old entries after 10 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
|
||||
self?.collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -3815,6 +4199,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID)
|
||||
SecureLogger.log("Successfully encrypted message, size: \(encryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
||||
|
||||
// Update last successful message time
|
||||
lastSuccessfulMessageTime[recipientPeerID] = Date()
|
||||
|
||||
// Send as Noise encrypted message
|
||||
let outerPacket = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
@@ -3832,5 +4219,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Failed to encrypt message
|
||||
SecureLogger.log("Failed to encrypt private message \(msgID) for \(recipientPeerID): \(error)", category: SecureLogger.encryption, level: .error)
|
||||
}
|
||||
} // End of encryptionQueue.async
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ class DeliveryTracker {
|
||||
private var sentAckIDs = Set<String>()
|
||||
|
||||
// Timeout configuration
|
||||
private let privateMessageTimeout: TimeInterval = 30 // 30 seconds
|
||||
private let roomMessageTimeout: TimeInterval = 60 // 1 minute
|
||||
private let favoriteTimeout: TimeInterval = 300 // 5 minutes for favorites
|
||||
private let privateMessageTimeout: TimeInterval = 120 // 2 minutes
|
||||
private let roomMessageTimeout: TimeInterval = 180 // 3 minutes
|
||||
private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites
|
||||
|
||||
// Retry configuration
|
||||
private let maxRetries = 3
|
||||
|
||||
@@ -25,9 +25,9 @@ enum EncryptionStatus: Equatable {
|
||||
case .noiseHandshaking:
|
||||
return "lock.rotation"
|
||||
case .noiseSecured:
|
||||
return "lock"
|
||||
return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock
|
||||
case .noiseVerified:
|
||||
return "lock.shield"
|
||||
return "lock.shield.fill" // Changed to filled version for consistency
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ class SecureLogger {
|
||||
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
|
||||
static let session = OSLog(subsystem: subsystem, category: "session")
|
||||
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
|
||||
|
||||
@@ -135,7 +145,8 @@ class SecureLogger {
|
||||
/// Format location information for logging
|
||||
private static func formatLocation(file: String, line: Int, function: String) -> String {
|
||||
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
|
||||
|
||||
@@ -1172,21 +1172,33 @@ class ChatViewModel: ObservableObject {
|
||||
// MARK: - Noise Protocol Support
|
||||
|
||||
func updateEncryptionStatusForPeers() {
|
||||
for peerID in connectedPeers {
|
||||
updateEncryptionStatusForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func updateEncryptionStatusForPeer(_ peerID: String) {
|
||||
let noiseService = meshService.getNoiseService()
|
||||
|
||||
for peerID in connectedPeers {
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
// Check if fingerprint is verified using our persisted data
|
||||
if let fingerprint = noiseService.getPeerFingerprint(peerID),
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
verifiedFingerprints.contains(fingerprint) {
|
||||
peerEncryptionStatus[peerID] = .noiseVerified
|
||||
} else {
|
||||
peerEncryptionStatus[peerID] = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// Always use Noise - no legacy encryption
|
||||
} else if noiseService.hasSession(with: peerID) {
|
||||
// Session exists but not established - handshaking
|
||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||
} else {
|
||||
// No session at all
|
||||
peerEncryptionStatus[peerID] = Optional.none
|
||||
}
|
||||
|
||||
// Force UI update
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1194,31 +1206,64 @@ class ChatViewModel: ObservableObject {
|
||||
// This must be a pure function - no state mutations allowed
|
||||
// to avoid SwiftUI update loops
|
||||
|
||||
// Check if we have a fingerprint for this peer
|
||||
let hasEstablished = meshService.getNoiseService().hasEstablishedSession(with: peerID)
|
||||
let hasSession = meshService.getNoiseService().hasSession(with: peerID)
|
||||
let storedStatus = peerEncryptionStatus[peerID]
|
||||
|
||||
// First check if we have an established session
|
||||
if hasEstablished {
|
||||
// We have encryption, now check if it's verified
|
||||
if let fingerprint = getFingerprint(for: peerID) {
|
||||
// Check if this fingerprint is verified
|
||||
if verifiedFingerprints.contains(fingerprint) {
|
||||
// Return verified if we have a Noise session
|
||||
if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
return .noiseVerified
|
||||
}
|
||||
} else if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
} else {
|
||||
return .noiseSecured
|
||||
}
|
||||
}
|
||||
// We have a session but no fingerprint yet - still secured
|
||||
return .noiseSecured
|
||||
}
|
||||
|
||||
// Check if handshaking
|
||||
if hasSession {
|
||||
return .noiseHandshaking
|
||||
}
|
||||
|
||||
// Fall back to stored status
|
||||
return peerEncryptionStatus[peerID] ?? .none
|
||||
let finalStatus = storedStatus ?? .none
|
||||
|
||||
// Only log occasionally to avoid spam
|
||||
if Int.random(in: 0..<100) == 0 {
|
||||
SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(finalStatus)", category: SecureLogger.security, level: .debug)
|
||||
}
|
||||
|
||||
return finalStatus
|
||||
}
|
||||
|
||||
// Update encryption status in appropriate places, not during view updates
|
||||
private func updateEncryptionStatus(for peerID: String) {
|
||||
let noiseService = meshService.getNoiseService()
|
||||
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
if let fingerprint = getFingerprint(for: peerID) {
|
||||
if verifiedFingerprints.contains(fingerprint) && meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
if verifiedFingerprints.contains(fingerprint) {
|
||||
peerEncryptionStatus[peerID] = .noiseVerified
|
||||
} else if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
} else {
|
||||
peerEncryptionStatus[peerID] = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// Session established but no fingerprint yet
|
||||
peerEncryptionStatus[peerID] = .noiseSecured
|
||||
}
|
||||
} else if noiseService.hasSession(with: peerID) {
|
||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||
} else {
|
||||
peerEncryptionStatus[peerID] = Optional.none
|
||||
}
|
||||
|
||||
// Trigger UI update
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,12 +1367,21 @@ class ChatViewModel: ObservableObject {
|
||||
// Set up authentication callback
|
||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
SecureLogger.log("ChatViewModel: Peer authenticated - \(peerID), fingerprint: \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
|
||||
// Update encryption status
|
||||
if self?.verifiedFingerprints.contains(fingerprint) == true {
|
||||
self?.peerEncryptionStatus[peerID] = .noiseVerified
|
||||
if self.verifiedFingerprints.contains(fingerprint) {
|
||||
self.peerEncryptionStatus[peerID] = .noiseVerified
|
||||
SecureLogger.log("ChatViewModel: Setting encryption status to noiseVerified for \(peerID)", category: SecureLogger.security, level: .info)
|
||||
} else {
|
||||
self?.peerEncryptionStatus[peerID] = .noiseSecured
|
||||
self.peerEncryptionStatus[peerID] = .noiseSecured
|
||||
SecureLogger.log("ChatViewModel: Setting encryption status to noiseSecured for \(peerID)", category: SecureLogger.security, level: .info)
|
||||
}
|
||||
|
||||
// Force UI update
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2024,6 +2078,17 @@ extension ChatViewModel: BitchatDelegate {
|
||||
// Remove ephemeral session from identity manager
|
||||
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
|
||||
|
||||
// Clear sent read receipts for this peer since they'll need to be resent after reconnection
|
||||
// Only clear receipts for messages from this specific peer
|
||||
if let messages = privateChats[peerID] {
|
||||
for message in messages {
|
||||
// Remove read receipts for messages FROM this peer (not TO this peer)
|
||||
if message.senderPeerID == peerID {
|
||||
sentReadReceipts.remove(message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve nickname using helper
|
||||
let displayName = resolveNickname(for: peerID)
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ final class IntegrationTests: XCTestCase {
|
||||
if packet.type == 0x01 {
|
||||
plainCount += 1
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,124 @@ final class IntegrationTests: XCTestCase {
|
||||
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() {
|
||||
// Create larger network
|
||||
for i in 5...10 {
|
||||
|
||||
@@ -295,6 +295,165 @@ final class NoiseProtocolTests: XCTestCase {
|
||||
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
|
||||
|
||||
func testHandshakePerformance() throws {
|
||||
|
||||
Reference in New Issue
Block a user