Merge pull request #304 from permissionlesstech/noise-handshake-improvements

Fix Noise handshake stability and session synchronization
This commit is contained in:
jack
2025-07-23 15:13:57 +02:00
committed by GitHub
11 changed files with 1157 additions and 94 deletions
+6
View File
@@ -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()
}
}
}
+2
View File
@@ -299,6 +299,7 @@ class NoiseHandshakeState {
throw NoiseError.handshakeComplete
}
SecureLogger.log("NoiseHandshake[\(role)]: Writing message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
var messageBuffer = Data()
let patterns = messagePatterns[currentPattern]
@@ -390,6 +391,7 @@ class NoiseHandshakeState {
throw NoiseError.handshakeComplete
}
SecureLogger.log("NoiseHandshake[\(role)]: Reading message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
var buffer = message
let patterns = messagePatterns[currentPattern]
+8 -2
View File
@@ -92,6 +92,7 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) {
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .info)
// Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder {
@@ -102,6 +103,7 @@ class NoiseSession {
remoteStaticKey: nil
)
state = .handshaking
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info)
}
guard case .handshaking = state, let handshake = handshakeState else {
@@ -110,6 +112,7 @@ class NoiseSession {
// Process incoming message
_ = try handshake.readMessage(message)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .info)
// Check if handshake is complete
if handshake.isHandshakeComplete() {
@@ -127,6 +130,7 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .info)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
return nil
@@ -134,6 +138,7 @@ class NoiseSession {
// Generate response
let response = try handshake.writeMessage()
sentHandshakeMessages.append(response)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .info)
// Check if handshake is complete after writing
if handshake.isHandshakeComplete() {
@@ -151,6 +156,7 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .info)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
}
@@ -162,7 +168,7 @@ class NoiseSession {
// MARK: - Transport
func encrypt(_ plaintext: Data) throws -> Data {
return try sessionQueue.sync {
return try sessionQueue.sync(flags: .barrier) {
guard case .established = state, let cipher = sendCipher else {
throw NoiseSessionError.notEstablished
}
@@ -172,7 +178,7 @@ class NoiseSession {
}
func decrypt(_ ciphertext: Data) throws -> Data {
return try sessionQueue.sync {
return try sessionQueue.sync(flags: .barrier) {
guard case .established = state, let cipher = receiveCipher else {
throw NoiseSessionError.notEstablished
}
+446 -58
View File
@@ -46,6 +46,9 @@ class BluetoothMeshService: NSObject {
private var discoveredPeripherals: [CBPeripheral] = []
private var connectedPeripherals: [String: CBPeripheral] = [:]
private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:]
private var lastConnectionTime: [String: Date] = [:] // Track when peers last connected
private var lastSuccessfulMessageTime: [String: Date] = [:] // Track last successful message exchange
private var lastHeardFromPeer: [String: Date] = [:] // Track last time we received ANY packet from peer
private var characteristic: CBMutableCharacteristic?
private var subscribedCentrals: [CBCentral] = []
// Thread-safe collections using concurrent queues
@@ -55,6 +58,10 @@ class BluetoothMeshService: NSObject {
private var peerRSSI: [String: NSNumber] = [:] // Track RSSI values for peers
private var peripheralRSSI: [String: NSNumber] = [:] // Track RSSI by peripheral ID during discovery
// Per-peer encryption queues to prevent nonce desynchronization
private var peerEncryptionQueues: [String: DispatchQueue] = [:]
private let encryptionQueuesLock = NSLock()
// MARK: - Peer Identity Rotation
// Mappings between ephemeral peer IDs and permanent fingerprints
private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint
@@ -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] = []
}
// 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
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 {
initiateNoiseHandshake(with: announcement.peerID)
// Add small delay on fresh startup to let connections stabilize
let lastConnection = lastConnectionTime[announcement.peerID] ?? Date.distantPast
let timeSinceConnection = Date().timeIntervalSince(lastConnection)
if timeSinceConnection > 60.0 { // Fresh connection
// Delay handshake initiation slightly for connection stability
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.initiateNoiseHandshake(with: announcement.peerID)
}
} else {
// Quick reconnection, initiate immediately
initiateNoiseHandshake(with: announcement.peerID)
}
} else {
// Send our identity back so they know we're ready
sendNoiseIdentityAnnounce(to: announcement.peerID)
@@ -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,20 +3442,48 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
private func sendPendingPrivateMessages(to peerID: String) {
messageQueue.async(flags: .barrier) { [weak self] in
guard let self = self,
let pendingMessages = self.pendingPrivateMessages[peerID] else { return }
guard let self = self else { return }
SecureLogger.log("Sending \(pendingMessages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info)
// Get pending messages with proper queue synchronization
let pendingMessages = self.collectionsQueue.sync {
return self.pendingPrivateMessages[peerID]
}
guard let messages = pendingMessages else { return }
SecureLogger.log("Sending \(messages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info)
// Clear pending messages for this peer
self.pendingPrivateMessages.removeValue(forKey: peerID)
self.collectionsQueue.sync(flags: .barrier) {
_ = self.pendingPrivateMessages.removeValue(forKey: peerID)
}
// Send each pending message
for (content, recipientNickname, messageID) in pendingMessages {
SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug)
// Use async to avoid blocking the queue
DispatchQueue.global().async { [weak self] in
self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
for (content, recipientNickname, messageID) in messages {
// Check if this is a read receipt
if content.hasPrefix("READ_RECEIPT:") {
// Extract the original message ID
let originalMessageID = String(content.dropFirst("READ_RECEIPT:".count))
SecureLogger.log("Sending queued read receipt for message \(originalMessageID) to \(peerID)", category: SecureLogger.session, level: .debug)
// Create and send the actual read receipt
let receipt = ReadReceipt(
originalMessageID: originalMessageID,
readerID: self.myPeerID,
readerNickname: recipientNickname // This is actually the reader's nickname
)
// Send the read receipt using the normal method
DispatchQueue.global().async { [weak self] in
self?.sendReadReceipt(receipt, to: peerID)
}
} else {
// Regular message
SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug)
// Use async to avoid blocking the queue
DispatchQueue.global().async { [weak self] in
self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
}
}
}
@@ -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 noiseService directly
// Use per-peer encryption queue to prevent nonce desynchronization
let encryptionQueue = getEncryptionQueue(for: recipientPeerID)
// Check if we have a Noise session with this peer
if !noiseService.hasEstablishedSession(with: recipientPeerID) {
SecureLogger.log("No Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
encryptionQueue.async { [weak self] in
guard let self = self else { return }
// Use noiseService directly
// Check if we have a Noise session with this peer
let hasSession = self.noiseService.hasEstablishedSession(with: recipientPeerID)
// Check if session is stale (no successful communication for a while)
var sessionIsStale = false
if hasSession {
let lastSuccess = lastSuccessfulMessageTime[recipientPeerID] ?? Date.distantPast
let sessionAge = Date().timeIntervalSince(lastSuccess)
if sessionAge > 600.0 { // More than 10 minutes since last successful message
sessionIsStale = true
SecureLogger.log("Session with \(recipientPeerID) is stale (last success: \(Int(sessionAge))s ago), will re-establish", category: SecureLogger.noise, level: .info)
}
}
if !hasSession || sessionIsStale {
if sessionIsStale {
// Clear stale session first
cleanupPeerCryptoState(recipientPeerID)
}
SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
// Apply tie-breaker logic for handshake initiation
if myPeerID < recipientPeerID {
@@ -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
}
}
+3 -3
View File
@@ -21,9 +21,9 @@ class DeliveryTracker {
private var sentAckIDs = Set<String>()
// Timeout configuration
private let privateMessageTimeout: TimeInterval = 30 // 30 seconds
private let roomMessageTimeout: TimeInterval = 60 // 1 minute
private let favoriteTimeout: TimeInterval = 300 // 5 minutes for favorites
private let privateMessageTimeout: TimeInterval = 120 // 2 minutes
private let roomMessageTimeout: TimeInterval = 180 // 3 minutes
private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites
// Retry configuration
private let maxRetries = 3
@@ -25,9 +25,9 @@ enum EncryptionStatus: Equatable {
case .noiseHandshaking:
return "lock.rotation"
case .noiseSecured:
return "lock"
return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock
case .noiseVerified:
return "lock.shield"
return "lock.shield.fill" // Changed to filled version for consistency
}
}
+12 -1
View File
@@ -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
+92 -27
View File
@@ -1172,21 +1172,33 @@ class ChatViewModel: ObservableObject {
// MARK: - Noise Protocol Support
func updateEncryptionStatusForPeers() {
for peerID in connectedPeers {
updateEncryptionStatusForPeer(peerID)
}
}
func updateEncryptionStatusForPeer(_ peerID: String) {
let noiseService = meshService.getNoiseService()
for peerID in connectedPeers {
if noiseService.hasEstablishedSession(with: peerID) {
// Check if fingerprint is verified using our persisted data
if let fingerprint = noiseService.getPeerFingerprint(peerID),
verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
if noiseService.hasEstablishedSession(with: peerID) {
// Check if fingerprint is verified using our persisted data
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
// Always use Noise - no legacy encryption
peerEncryptionStatus[peerID] = .noiseHandshaking
peerEncryptionStatus[peerID] = .noiseSecured
}
} else if noiseService.hasSession(with: peerID) {
// Session exists but not established - handshaking
peerEncryptionStatus[peerID] = .noiseHandshaking
} else {
// No session at all
peerEncryptionStatus[peerID] = Optional.none
}
// Force UI update
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
@@ -1194,31 +1206,64 @@ class ChatViewModel: ObservableObject {
// This must be a pure function - no state mutations allowed
// to avoid SwiftUI update loops
// Check if we have a fingerprint for this peer
if let fingerprint = getFingerprint(for: peerID) {
// Check if this fingerprint is verified
if verifiedFingerprints.contains(fingerprint) {
// Return verified if we have a Noise session
if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
let hasEstablished = meshService.getNoiseService().hasEstablishedSession(with: peerID)
let hasSession = meshService.getNoiseService().hasSession(with: peerID)
let storedStatus = peerEncryptionStatus[peerID]
// First check if we have an established session
if hasEstablished {
// We have encryption, now check if it's verified
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
return .noiseVerified
} else {
return .noiseSecured
}
} else if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
return .noiseSecured
}
// We have a session but no fingerprint yet - still secured
return .noiseSecured
}
// Check if handshaking
if hasSession {
return .noiseHandshaking
}
// Fall back to stored status
return peerEncryptionStatus[peerID] ?? .none
let finalStatus = storedStatus ?? .none
// Only log occasionally to avoid spam
if Int.random(in: 0..<100) == 0 {
SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(finalStatus)", category: SecureLogger.security, level: .debug)
}
return finalStatus
}
// Update encryption status in appropriate places, not during view updates
private func updateEncryptionStatus(for peerID: String) {
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) && meshService.getNoiseService().hasEstablishedSession(with: peerID) {
peerEncryptionStatus[peerID] = .noiseVerified
} else if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} else {
// Session established but no fingerprint yet
peerEncryptionStatus[peerID] = .noiseSecured
}
} else if noiseService.hasSession(with: peerID) {
peerEncryptionStatus[peerID] = .noiseHandshaking
} else {
peerEncryptionStatus[peerID] = Optional.none
}
// Trigger UI update
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
@@ -1322,12 +1367,21 @@ class ChatViewModel: ObservableObject {
// Set up authentication callback
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self = self else { return }
SecureLogger.log("ChatViewModel: Peer authenticated - \(peerID), fingerprint: \(fingerprint)", category: SecureLogger.security, level: .info)
// Update encryption status
if self?.verifiedFingerprints.contains(fingerprint) == true {
self?.peerEncryptionStatus[peerID] = .noiseVerified
if self.verifiedFingerprints.contains(fingerprint) {
self.peerEncryptionStatus[peerID] = .noiseVerified
SecureLogger.log("ChatViewModel: Setting encryption status to noiseVerified for \(peerID)", category: SecureLogger.security, level: .info)
} else {
self?.peerEncryptionStatus[peerID] = .noiseSecured
self.peerEncryptionStatus[peerID] = .noiseSecured
SecureLogger.log("ChatViewModel: Setting encryption status to noiseSecured for \(peerID)", category: SecureLogger.security, level: .info)
}
// Force UI update
self.objectWillChange.send()
}
}
@@ -2024,6 +2078,17 @@ extension ChatViewModel: BitchatDelegate {
// Remove ephemeral session from identity manager
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
// Clear sent read receipts for this peer since they'll need to be resent after reconnection
// Only clear receipts for messages from this specific peer
if let messages = privateChats[peerID] {
for message in messages {
// Remove read receipts for messages FROM this peer (not TO this peer)
if message.senderPeerID == peerID {
sentReadReceipts.remove(message.id)
}
}
}
// Resolve nickname using helper
let displayName = resolveNickname(for: peerID)
+119 -1
View File
@@ -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 {
+159
View File
@@ -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 {