mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:45:18 +00:00
Fix Noise handshake failures and implement binary protocol migration
## Summary - Added timestamps to SecureLogger for precise timing analysis - Implemented NoiseHandshakeCoordinator to prevent race conditions - Added deterministic role selection based on peer ID comparison - Implemented proper handshake state machine with retry logic - Added duplicate message detection for handshake messages - Improved logging and diagnostics for handshake debugging ## Details The coordinator ensures only one peer initiates handshakes by using deterministic role selection (lower peer ID initiates). This prevents the simultaneous handshake attempts that were causing failures. The state machine tracks handshake progress and handles retries with exponential backoff. ## Testing Successfully builds with no errors or warnings. The implementation should resolve the "establishing encryption" stuck state issue by ensuring proper handshake coordination between peers.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//
|
||||
// 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
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:
|
||||
stateDesc = "responding"
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,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] = [:]
|
||||
@@ -519,6 +520,13 @@ class BluetoothMeshService: NSObject {
|
||||
self?.cleanupStalePeers()
|
||||
}
|
||||
|
||||
// Log handshake states periodically for debugging
|
||||
#if DEBUG
|
||||
Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
|
||||
self?.handshakeCoordinator.logHandshakeStates()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Schedule first peer ID rotation
|
||||
scheduleNextRotation()
|
||||
|
||||
@@ -3276,16 +3284,27 @@ 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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
|
||||
@@ -3310,8 +3329,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
|
||||
} 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,6 +3341,22 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Use noiseService directly
|
||||
SecureLogger.logHandshake("processing \(isInitiation ? "init" : "response")", peerID: peerID, success: true)
|
||||
|
||||
// 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) {
|
||||
@@ -3346,6 +3384,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
unlockRotation()
|
||||
|
||||
// Session established successfully
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
|
||||
// Clear handshake attempt time on success
|
||||
handshakeAttemptTimes.removeValue(forKey: peerID)
|
||||
@@ -3372,8 +3411,11 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -3581,6 +3623,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
|
||||
// Clean up any Noise session
|
||||
noiseService.removePeer(peerID)
|
||||
handshakeCoordinator.resetHandshakeState(for: peerID)
|
||||
|
||||
// Notify delegate about incompatible peer disconnection
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user