From b255355fed279c9f1ec87caba3ad5970229833a2 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 19:05:39 +0200 Subject: [PATCH] Fix ordinary Noise handshake races --- bitchat/Noise/NoiseSecurityConstants.swift | 20 + bitchat/Noise/NoiseSessionError.swift | 6 + bitchat/Noise/NoiseSessionManager.swift | 807 +++++++++++++++-- .../Services/BLE/BLENoisePacketHandler.swift | 8 + bitchat/Services/BLE/BLEService.swift | 132 ++- bitchat/Services/NoiseEncryptionService.swift | 90 +- bitchatTests/BLEServiceCoreTests.swift | 18 +- bitchatTests/Noise/NoiseCoverageTests.swift | 23 +- .../Services/BLENoisePacketHandlerTests.swift | 18 + .../NoiseEncryptionServiceTests.swift | 857 +++++++++++++++++- 10 files changed, 1850 insertions(+), 129 deletions(-) diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index 95d358f2..ac98eaf6 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -37,9 +37,29 @@ enum NoiseSecurityConstants { // Noise XX message 1 contains only the initiator's 32-byte ephemeral key. static let xxInitialMessageSize = 32 + // Bounds an ordinary initiator whose message 1 or 2 is lost. + static let ordinaryHandshakeTimeout: TimeInterval = 10 + // Bounds the receive-only rollback quarantine created by an unauthenticated // inbound message 1. A lost message 3 must not strand outbound traffic. static let ordinaryResponderHandshakeTimeout: TimeInterval = 20 + + // A released client may immediately retry after both crossed initiators + // yielded. Give that unilateral retry a brief head start before the + // patched side spends its one bounded recovery. + static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2 + + // Rate-limited recovery remains actionable without spinning. + static let handshakeRateLimitRecoveryDelay: TimeInterval = 60 + + // Covers only reordering between a winning message 3 and the losing + // crossed message 1. + static let recentInitiatorCompletionGracePeriod: TimeInterval = 1 + + // After unauthenticated responder rollback, reject another attempt long + // enough that paced message 1 traffic cannot keep outbound paused. A + // legitimate peer converges through the one manager-owned local retry. + static let ordinaryReconnectRollbackCooldown: TimeInterval = 60 // Session timeout - sessions older than this should be renegotiated static let sessionTimeout: TimeInterval = 86400 // 24 hours diff --git a/bitchat/Noise/NoiseSessionError.swift b/bitchat/Noise/NoiseSessionError.swift index 18dc99b0..ad098bbc 100644 --- a/bitchat/Noise/NoiseSessionError.swift +++ b/bitchat/Noise/NoiseSessionError.swift @@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable { case alreadyEstablished case peerIdentityMismatch } + +/// The manager owns the exact attempt's one bounded recovery. Packet handling +/// must not launch its historical second, immediate restart for this failure. +struct NoiseManagedHandshakeFailure: Error { + let underlying: Error +} diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 8bf27a0e..dfe9377b 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -21,6 +21,16 @@ struct NoiseHandshakeInitiation: Equatable, Sendable { let attemptID: UUID } +struct NoiseHandshakeRecoveryRequest: Equatable, Sendable { + let peerID: PeerID + fileprivate let recoveryID: UUID +} + +enum NoiseHandshakeRecoveryPreparation { + case ordinary(NoiseHandshakeInitiation) + case transferred +} + final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] /// Opaque identity for each exact entry in `sessions`. The generation is @@ -30,6 +40,13 @@ final class NoiseSessionManager { /// One-time handoff tokens prevent a prepared XX message 1 from leaving /// after an inbound collision has already changed this peer's role. private var ordinaryInitiationIDs: [PeerID: UUID] = [:] + private var ordinaryInitiatorTimeouts: [PeerID: DispatchWorkItem] = [:] + private var ordinaryInitiatorRetryNotifications: [PeerID: Bool] = [:] + private var ordinaryResponderTimeouts: [PeerID: DispatchWorkItem] = [:] + private var ordinaryResponderDeadlines: [PeerID: DispatchTime] = [:] + private var ordinaryResponderRetryNotifications: [PeerID: Bool] = [:] + private var ordinaryRespondersCreatedByYield: Set = [] + private var recentOrdinaryInitiatorCompletions: [PeerID: Date] = [:] private struct QuarantinedTransport { let session: NoiseSession let generation: UUID @@ -43,23 +60,44 @@ final class NoiseSessionManager { /// claimed static identity, then is discarded on success or restored on /// bounded failure. private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:] - private var quarantinedResponderTimeouts: [PeerID: DispatchWorkItem] = [:] + private var quarantineRollbackCooldownUntil: [PeerID: Date] = [:] + private var suppressedInitiationRecoveryTimeouts: [PeerID: DispatchWorkItem] = [:] + private var delayedHandshakeRecoveryWorkItems: [PeerID: DispatchWorkItem] = [:] + private var handshakeRecoveryCallbackIDs: [PeerID: UUID] = [:] + private var pendingHandshakeRecoveryIDs: [PeerID: UUID] = [:] private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession + private let localPeerID: PeerID + private let ordinaryHandshakeTimeout: TimeInterval private let ordinaryResponderHandshakeTimeout: TimeInterval + private let recentInitiatorCompletionGracePeriod: TimeInterval + private let ordinaryReconnectRollbackCooldown: TimeInterval private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? var onSessionRestored: ((PeerID, UUID) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? + var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)? init( localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, ordinaryResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown ) { + self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation) + self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout + self.recentInitiatorCompletionGracePeriod = + recentInitiatorCompletionGracePeriod + self.ordinaryReconnectRollbackCooldown = + ordinaryReconnectRollbackCooldown self.sessionFactory = { peerID, role in SecureNoiseSession( peerID: peerID, @@ -72,13 +110,25 @@ final class NoiseSessionManager { #if DEBUG init( - localStaticKey _: Curve25519.KeyAgreement.PrivateKey, + localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain _: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, ordinaryResponderHandshakeTimeout: TimeInterval = NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown, sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession ) { + self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation) + self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout + self.recentInitiatorCompletionGracePeriod = + recentInitiatorCompletionGracePeriod + self.ordinaryReconnectRollbackCooldown = + ordinaryReconnectRollbackCooldown self.sessionFactory = sessionFactory } #endif @@ -90,6 +140,110 @@ final class NoiseSessionManager { return sessions[peerID] } } + + /// Transfers one bounded recovery generation to whatever ordinary XX + /// handshake currently owns the peer, or starts the generation's single + /// retry. The request token prevents stale transport callbacks from + /// creating additional attempts. + func prepareHandshakeRecovery( + _ request: NoiseHandshakeRecoveryRequest, + authorizeAttempt: () throws -> Void + ) throws -> NoiseHandshakeRecoveryPreparation? { + try managerQueue.sync(flags: .barrier) { + let peerID = request.peerID + guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { + return nil + } + + if let current = sessions[peerID], + current.getState() == .handshaking { + if current.role == .initiator { + scheduleOrdinaryInitiatorTimeoutLocked( + current, + for: peerID, + notifyOnTimeout: true + ) + } else { + // A recovery may transfer to a responder that raced ahead + // of the callback. Preserve an existing quarantine deadline + // so duplicate unauthenticated message 1 packets cannot + // extend the outbound pause. + scheduleOrdinaryResponderTimeoutLocked( + current, + for: peerID, + notifyOnTimeout: true, + createdByYield: true, + rearmDeadline: quarantinedTransports[peerID] == nil + ) + } + return .transferred + } + + do { + try authorizeAttempt() + } catch { + redispatchHandshakeRecoveryLocked( + request, + after: NoiseSecurityConstants.handshakeRateLimitRecoveryDelay + ) + throw error + } + + let next = sessionFactory(peerID, .initiator) + let payload: Data + do { + payload = try next.startHandshake() + } catch { + next.reset() + redispatchHandshakeRecoveryLocked( + request, + after: NoiseSecurityConstants.handshakeCollisionRecoveryDelay + ) + throw error + } + + // Start before retiring a working transport. If preparation fails, + // the established session and its generation are untouched. + if let current = sessions.removeValue(forKey: peerID) { + current.reset() + } + if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { + quarantined.session.reset() + } + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + cancelSuppressedInitiationRecoveryLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + + let attemptID = UUID() + sessions[peerID] = next + sessionGenerations[peerID] = UUID() + ordinaryInitiationIDs[peerID] = attemptID + // This is the one retry owned by `request`; it may time out but + // must not recursively mint another generation. + scheduleOrdinaryInitiatorTimeoutLocked( + next, + for: peerID, + notifyOnTimeout: false + ) + consumeHandshakeRecoveryLocked(request) + return .ordinary( + NoiseHandshakeInitiation( + payload: payload, + attemptID: attemptID + ) + ) + } + } + + func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) { + managerQueue.sync(flags: .barrier) { + consumeHandshakeRecoveryLocked(request) + } + } func removeSession(for peerID: PeerID) { managerQueue.sync(flags: .barrier) { @@ -104,9 +258,14 @@ final class NoiseSessionManager { if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { quarantined.session.reset() } - quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel() sessionGenerations.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + cancelSuppressedInitiationRecoveryLocked(for: peerID) + cancelDelayedHandshakeRecoveryLocked(for: peerID) } func removeAllSessions() { @@ -117,14 +276,34 @@ final class NoiseSessionManager { for (_, quarantined) in quarantinedTransports { quarantined.session.reset() } - for (_, timeout) in quarantinedResponderTimeouts { + for (_, timeout) in ordinaryInitiatorTimeouts { + timeout.cancel() + } + for (_, timeout) in ordinaryResponderTimeouts { + timeout.cancel() + } + for (_, timeout) in suppressedInitiationRecoveryTimeouts { + timeout.cancel() + } + for (_, timeout) in delayedHandshakeRecoveryWorkItems { timeout.cancel() } sessions.removeAll() sessionGenerations.removeAll() ordinaryInitiationIDs.removeAll() + ordinaryInitiatorTimeouts.removeAll() + ordinaryInitiatorRetryNotifications.removeAll() + ordinaryResponderTimeouts.removeAll() + ordinaryResponderDeadlines.removeAll() + ordinaryResponderRetryNotifications.removeAll() + ordinaryRespondersCreatedByYield.removeAll() + recentOrdinaryInitiatorCompletions.removeAll() quarantinedTransports.removeAll() - quarantinedResponderTimeouts.removeAll() + quarantineRollbackCooldownUntil.removeAll() + suppressedInitiationRecoveryTimeouts.removeAll() + delayedHandshakeRecoveryWorkItems.removeAll() + handshakeRecoveryCallbackIDs.removeAll() + pendingHandshakeRecoveryIDs.removeAll() } } @@ -140,11 +319,9 @@ final class NoiseSessionManager { // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - existingSession.reset() + removeSessionLocked(for: peerID) } + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) // Create new initiator session let session = sessionFactory(peerID, .initiator) @@ -153,12 +330,19 @@ final class NoiseSessionManager { do { let handshakeData = try session.startHandshake() + scheduleOrdinaryInitiatorTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: false + ) + cancelDelayedHandshakeRecoveryLocked(for: peerID) return handshakeData } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) session.reset() SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error @@ -166,12 +350,61 @@ final class NoiseSessionManager { } } + /// Atomically starts an ordinary initiator only when no other session + /// already owns the peer. BLE discovery can report the same peer on + /// multiple links; combining the absence check, authorization, creation, + /// and handoff token prevents two different message 1 packets escaping. + func initiateHandshakeIfAbsent( + with peerID: PeerID, + notifyOnTimeout: Bool, + authorize: () throws -> Void + ) throws -> NoiseHandshakeInitiation? { + try managerQueue.sync(flags: .barrier) { + guard sessions[peerID] == nil, + quarantinedTransports[peerID] == nil else { + return nil + } + try authorize() + + let session = sessionFactory(peerID, .initiator) + do { + let payload = try session.startHandshake() + let attemptID = UUID() + sessions[peerID] = session + sessionGenerations[peerID] = UUID() + ordinaryInitiationIDs[peerID] = attemptID + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + scheduleOrdinaryInitiatorTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: notifyOnTimeout + ) + cancelDelayedHandshakeRecoveryLocked(for: peerID) + return NoiseHandshakeInitiation( + payload: payload, + attemptID: attemptID + ) + } catch { + session.reset() + SecureLogger.error( + .handshakeFailed( + peerID: peerID.id, + error: error.localizedDescription + ) + ) + throw error + } + } + } + /// Prepares an ordinary reconnect before atomically retiring the current /// transport. Authorization and handshake-start failure leave the working /// session untouched. Once this returns, encryption observes only the new /// handshaking session and must queue until it establishes. func initiateReconnectHandshake( with peerID: PeerID, + notifyOnTimeout: Bool, authorize: () throws -> Void ) throws -> NoiseHandshakeInitiation { try managerQueue.sync(flags: .barrier) { @@ -196,11 +429,29 @@ final class NoiseSessionManager { throw error } - removeSessionLocked(for: peerID) + // Retire only after the replacement initiator has successfully + // produced message 1. Do not use the broad removal helper here: + // this transition owns the exact new session installed below. + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + cancelSuppressedInitiationRecoveryLocked(for: peerID) + cancelDelayedHandshakeRecoveryLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + established.reset() + let attemptID = UUID() sessions[peerID] = next sessionGenerations[peerID] = UUID() ordinaryInitiationIDs[peerID] = attemptID + scheduleOrdinaryInitiatorTimeoutLocked( + next, + for: peerID, + notifyOnTimeout: notifyOnTimeout + ) return NoiseHandshakeInitiation( payload: payload, attemptID: attemptID @@ -222,6 +473,16 @@ final class NoiseSessionManager { return nil } ordinaryInitiationIDs.removeValue(forKey: peerID) + let notifyOnTimeout = + ordinaryInitiatorRetryNotifications[peerID] ?? false + // Preparation and on-wire exchange have independent bounds. Once + // BLE owns these exact bytes, give the peer the full response + // window without changing retry ownership. + scheduleOrdinaryInitiatorTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: notifyOnTimeout + ) return initiation.payload } } @@ -253,59 +514,170 @@ final class NoiseSessionManager { generation: UUID )? ) = try managerQueue.sync(flags: .barrier) { + var yieldedInitiatorShouldRetry = false + var didYieldLocalInitiator = false + var inheritedResponderShouldRetry = false + var inheritedResponderWasCreatedByYield = false + let existingAtIngress = sessions[peerID] + let isFreshInitiation = + message.count == NoiseSecurityConstants.xxInitialMessageSize + || existingAtIngress == nil + || ( + existingAtIngress?.isEstablished() == true + && message.count + > NoiseSecurityConstants.xxInitialMessageSize + ) + + if isFreshInitiation { + if let cooldownUntil = quarantineRollbackCooldownUntil[peerID] { + if cooldownUntil > Date(), + sessions[peerID]?.isEstablished() == true { + SecureLogger.debug( + "Ignoring unauthenticated reconnect initiation during rollback cooldown for \(peerID)", + category: .session + ) + return (nil, nil) + } + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + } + + if suppressedInitiationRecoveryTimeouts[peerID] != nil, + localPeerID < peerID.toShort(), + sessions[peerID]?.isEstablished() == true { + SecureLogger.debug( + "Coalescing duplicate initiation while convergence recovery is pending for \(peerID)", + category: .session + ) + return (nil, nil) + } + + if let completedAt = recentOrdinaryInitiatorCompletions[peerID] { + let stillInGrace = Date().timeIntervalSince(completedAt) + < recentInitiatorCompletionGracePeriod + if stillInGrace, + localPeerID < peerID.toShort(), + let established = sessions[peerID], + established.isEstablished() { + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + scheduleSuppressedInitiationRecoveryLocked( + established, + completedAt: completedAt, + for: peerID + ) + SecureLogger.debug( + "Deferring delayed crossed initiation from \(peerID) after initiator completion", + category: .session + ) + return (nil, nil) + } + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + } + + if let ordinaryInitiator = sessions[peerID], + ordinaryInitiator.role == .initiator, + ordinaryInitiator.getState() == .handshaking { + if localPeerID < peerID.toShort() { + SecureLogger.debug( + "Ignoring crossed ordinary initiation from \(peerID); keeping deterministic initiator role", + category: .session + ) + return (nil, nil) + } + yieldedInitiatorShouldRetry = + ordinaryInitiatorRetryNotifications[peerID] ?? false + didYieldLocalInitiator = true + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + ordinaryInitiator.reset() + } + } + let session: NoiseSession - if let existing = sessions[peerID], - existing.isEstablished() - || message.count - == NoiseSecurityConstants.xxInitialMessageSize { + if let existing = sessions[peerID] { if existing.isEstablished(), - let generation = sessionGenerations[peerID] { - // Message 1 cannot prove that the remote still owns its - // old keys. Remove them from every sending/generation API - // immediately, but retain the object solely for bounded - // rollback until the ordinary responder authenticates. - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - if let prior = quarantinedTransports.updateValue( - QuarantinedTransport( - session: existing, - generation: generation, - rollbackDeadline: .now() - + ordinaryResponderHandshakeTimeout - ), - forKey: peerID - ) { - prior.session.reset() - } - } else { - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - existing.reset() - } - let replacement = sessionFactory(peerID, .responder) - sessions[peerID] = replacement - sessionGenerations[peerID] = UUID() - session = replacement - if quarantinedTransports[peerID] != nil { - scheduleQuarantinedResponderTimeoutLocked( - replacement, - for: peerID + !isFreshInitiation { + // An established ordinary XX transport has no remaining + // handshake messages to consume. Unauthenticated garbage + // must not tear down its working keys. + SecureLogger.debug( + "Ignoring non-initial handshake bytes for established peer \(peerID)", + category: .session ) + return (nil, nil) + } + if isFreshInitiation { + if existing.isEstablished(), + let generation = sessionGenerations[peerID] { + // Message 1 is unauthenticated. Remove the old + // transport from every outbound/generation API now, + // retaining it only as receive-only rollback state. + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + if let prior = quarantinedTransports.updateValue( + QuarantinedTransport( + session: existing, + generation: generation, + rollbackDeadline: .now() + + ordinaryResponderHandshakeTimeout + ), + forKey: peerID + ) { + prior.session.reset() + } + } else { + inheritedResponderShouldRetry = + ordinaryResponderRetryNotifications[peerID] ?? false + inheritedResponderWasCreatedByYield = + ordinaryRespondersCreatedByYield.contains(peerID) + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + // Keep the first responder/quarantine deadline. A + // repeated message 1 may refresh message 2, but never + // refreshes the attacker's outbound-pause budget. + cancelOrdinaryResponderTimeoutLocked( + for: peerID, + preserveDeadline: true + ) + existing.reset() + } + + let replacement = sessionFactory(peerID, .responder) + sessions[peerID] = replacement + sessionGenerations[peerID] = UUID() + session = replacement + } else { + session = existing } - } else if let existing = sessions[peerID] { - session = existing } else { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession sessionGenerations[peerID] = UUID() session = newSession } - - // Process the handshake message within the synchronized block + do { + if isFreshInitiation, + session.role == .responder { + scheduleOrdinaryResponderTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: + yieldedInitiatorShouldRetry + || inheritedResponderShouldRetry, + createdByYield: + didYieldLocalInitiator + || inheritedResponderWasCreatedByYield + ) + } + let response = try session.processHandshakeMessage(message) // Check the exact session that processed this message. A @@ -324,12 +696,21 @@ final class NoiseSessionManager { if let quarantined = quarantinedTransports.removeValue( forKey: peerID ) { - quarantinedResponderTimeouts.removeValue( - forKey: peerID - )?.cancel() quarantined.session.reset() } ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + if session.role == .initiator { + recentOrdinaryInitiatorCompletions[peerID] = Date() + } else { + recentOrdinaryInitiatorCompletions.removeValue( + forKey: peerID + ) + } + cancelSuppressedInitiationRecoveryLocked(for: peerID) + cancelDelayedHandshakeRecoveryLocked(for: peerID) guard let generation = sessionGenerations[peerID] else { throw NoiseEncryptionError.sessionNotEstablished } @@ -338,19 +719,36 @@ final class NoiseSessionManager { return (response, establishedSession) } catch { + var shouldRequestRecovery = false + var shouldSuppressImmediateHandlerRestart = false + if session.role == .initiator { + shouldRequestRecovery = + ordinaryInitiatorRetryNotifications[peerID] ?? false + shouldSuppressImmediateHandlerRestart = true + } else { + shouldRequestRecovery = + ordinaryResponderRetryNotifications[peerID] ?? false + shouldSuppressImmediateHandlerRestart = + ordinaryRespondersCreatedByYield.contains(peerID) + } + if let storedSession = sessions[peerID], storedSession === session { _ = sessions.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID) } ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) session.reset() - quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel() + let restoredGeneration: UUID? if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { sessions[peerID] = quarantined.session sessionGenerations[peerID] = quarantined.generation restoredGeneration = quarantined.generation + markRollbackCooldownLocked(for: peerID) } else { restoredGeneration = nil } @@ -362,8 +760,34 @@ final class NoiseSessionManager { } self?.onSessionFailed?(peerID, error) } + + let isIdentityMismatch = + (error as? NoiseSessionError) == .peerIdentityMismatch + if pendingHandshakeRecoveryIDs[peerID] != nil { + shouldSuppressImmediateHandlerRestart = true + } + if shouldRequestRecovery, !isIdentityMismatch { + requestHandshakeRecovery( + for: peerID, + after: NoiseSecurityConstants + .handshakeCollisionRecoveryDelay + ) + } else if isIdentityMismatch, + let recoveryID = pendingHandshakeRecoveryIDs[peerID] { + redispatchHandshakeRecoveryLocked( + NoiseHandshakeRecoveryRequest( + peerID: peerID, + recoveryID: recoveryID + ), + after: NoiseSecurityConstants + .handshakeCollisionRecoveryDelay + ) + } SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) + if shouldSuppressImmediateHandlerRestart, !isIdentityMismatch { + throw NoiseManagedHandshakeFailure(underlying: error) + } throw error } } @@ -377,45 +801,261 @@ final class NoiseSessionManager { ) } - private func scheduleQuarantinedResponderTimeoutLocked( - _ responder: NoiseSession, - for peerID: PeerID + private func scheduleOrdinaryInitiatorTimeoutLocked( + _ session: NoiseSession, + for peerID: PeerID, + notifyOnTimeout: Bool ) { - quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel() - let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + ordinaryInitiatorRetryNotifications[peerID] = notifyOnTimeout + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in guard let self, - let responder, + let session, let current = self.sessions[peerID], - current === responder, - current.role == .responder, - current.getState() == .handshaking, - let quarantined = self.quarantinedTransports.removeValue( - forKey: peerID - ) else { + current === session, + current.role == .initiator, + current.getState() == .handshaking else { return } _ = self.sessions.removeValue(forKey: peerID) self.sessionGenerations.removeValue(forKey: peerID) self.ordinaryInitiationIDs.removeValue(forKey: peerID) - self.quarantinedResponderTimeouts.removeValue(forKey: peerID) - responder.reset() - self.sessions[peerID] = quarantined.session - self.sessionGenerations[peerID] = quarantined.generation + self.ordinaryInitiatorTimeouts.removeValue(forKey: peerID) + self.ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) + self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + session.reset() SecureLogger.debug( - "Ordinary responder handshake with \(peerID) timed out; restored quarantined transport", + "Ordinary initiator handshake with \(peerID) timed out", category: .session ) - DispatchQueue.global().async { [weak self] in - self?.onSessionRestored?(peerID, quarantined.generation) + + if notifyOnTimeout { + self.requestHandshakeRecovery(for: peerID) } } - quarantinedResponderTimeouts[peerID] = timeout - guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else { - quarantinedResponderTimeouts.removeValue(forKey: peerID) + ordinaryInitiatorTimeouts[peerID] = timeout + managerQueue.asyncAfter( + deadline: .now() + ordinaryHandshakeTimeout, + execute: timeout + ) + } + + private func cancelOrdinaryInitiatorTimeoutLocked(for peerID: PeerID) { + ordinaryInitiatorTimeouts.removeValue(forKey: peerID)?.cancel() + ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) + } + + private func scheduleOrdinaryResponderTimeoutLocked( + _ session: NoiseSession, + for peerID: PeerID, + notifyOnTimeout: Bool, + createdByYield: Bool, + rearmDeadline: Bool = false + ) { + let previousDeadline = ordinaryResponderDeadlines[peerID] + cancelOrdinaryResponderTimeoutLocked( + for: peerID, + preserveDeadline: true + ) + ordinaryResponderRetryNotifications[peerID] = notifyOnTimeout + if createdByYield { + ordinaryRespondersCreatedByYield.insert(peerID) + } + + let deadline: DispatchTime + if let rollbackDeadline = + quarantinedTransports[peerID]?.rollbackDeadline { + deadline = rollbackDeadline + } else if !rearmDeadline, let previousDeadline { + deadline = previousDeadline + } else { + deadline = .now() + ordinaryResponderHandshakeTimeout + } + ordinaryResponderDeadlines[peerID] = deadline + + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in + guard let self, + let session, + let current = self.sessions[peerID], + current === session, + current.role == .responder, + current.getState() == .handshaking else { + return + } + + _ = self.sessions.removeValue(forKey: peerID) + self.sessionGenerations.removeValue(forKey: peerID) + self.ordinaryInitiationIDs.removeValue(forKey: peerID) + self.ordinaryResponderTimeouts.removeValue(forKey: peerID) + self.ordinaryResponderDeadlines.removeValue(forKey: peerID) + self.ordinaryResponderRetryNotifications.removeValue(forKey: peerID) + self.ordinaryRespondersCreatedByYield.remove(peerID) + self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + session.reset() + + let restored: QuarantinedTransport? + if let quarantined = + self.quarantinedTransports.removeValue(forKey: peerID) { + self.sessions[peerID] = quarantined.session + self.sessionGenerations[peerID] = quarantined.generation + self.markRollbackCooldownLocked(for: peerID) + restored = quarantined + } else { + restored = nil + } + + SecureLogger.debug( + restored == nil + ? "Ordinary responder handshake with \(peerID) timed out" + : "Ordinary responder handshake with \(peerID) timed out; restored quarantined transport", + category: .session + ) + if let restored { + DispatchQueue.global().async { [weak self] in + self?.onSessionRestored?(peerID, restored.generation) + } + } + + // A rollback always owns one local convergence attempt. The retry + // retires the restored session atomically, so an attacker cannot + // pace unauthenticated message 1 packets to pause outbound forever. + if notifyOnTimeout || restored != nil { + self.requestHandshakeRecovery(for: peerID) + } + } + ordinaryResponderTimeouts[peerID] = timeout + managerQueue.asyncAfter(deadline: deadline, execute: timeout) + } + + private func cancelOrdinaryResponderTimeoutLocked( + for peerID: PeerID, + preserveDeadline: Bool = false + ) { + ordinaryResponderTimeouts.removeValue(forKey: peerID)?.cancel() + if !preserveDeadline { + ordinaryResponderDeadlines.removeValue(forKey: peerID) + } + ordinaryResponderRetryNotifications.removeValue(forKey: peerID) + ordinaryRespondersCreatedByYield.remove(peerID) + } + + private func markRollbackCooldownLocked(for peerID: PeerID) { + quarantineRollbackCooldownUntil[peerID] = + Date().addingTimeInterval(ordinaryReconnectRollbackCooldown) + } + + private func scheduleSuppressedInitiationRecoveryLocked( + _ establishedSession: NoiseSession, + completedAt: Date, + for peerID: PeerID + ) { + cancelSuppressedInitiationRecoveryLocked(for: peerID) + let elapsed = max(0, Date().timeIntervalSince(completedAt)) + let remainingGrace = max( + 0, + recentInitiatorCompletionGracePeriod - elapsed + ) + let timeout = DispatchWorkItem(flags: .barrier) { + [weak self, weak establishedSession] in + guard let self, + let establishedSession, + let current = self.sessions[peerID], + current === establishedSession, + current.isEstablished() else { + return + } + + self.suppressedInitiationRecoveryTimeouts.removeValue( + forKey: peerID + ) + self.requestHandshakeRecovery(for: peerID) + } + suppressedInitiationRecoveryTimeouts[peerID] = timeout + managerQueue.asyncAfter( + deadline: .now() + remainingGrace, + execute: timeout + ) + } + + private func cancelSuppressedInitiationRecoveryLocked(for peerID: PeerID) { + suppressedInitiationRecoveryTimeouts.removeValue(forKey: peerID)? + .cancel() + } + + private func requestHandshakeRecovery( + for peerID: PeerID, + after delay: TimeInterval = 0 + ) { + cancelDelayedHandshakeRecoveryLocked(for: peerID) + let request = NoiseHandshakeRecoveryRequest( + peerID: peerID, + recoveryID: UUID() + ) + pendingHandshakeRecoveryIDs[peerID] = request.recoveryID + scheduleHandshakeRecoveryCallbackLocked(request, after: delay) + } + + private func redispatchHandshakeRecoveryLocked( + _ request: NoiseHandshakeRecoveryRequest, + after delay: TimeInterval + ) { + guard pendingHandshakeRecoveryIDs[request.peerID] + == request.recoveryID else { return } - managerQueue.asyncAfter(deadline: deadline, execute: timeout) + scheduleHandshakeRecoveryCallbackLocked(request, after: delay) + } + + private func scheduleHandshakeRecoveryCallbackLocked( + _ request: NoiseHandshakeRecoveryRequest, + after delay: TimeInterval + ) { + let peerID = request.peerID + guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { + return + } + + delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() + let callbackID = UUID() + handshakeRecoveryCallbackIDs[peerID] = callbackID + let callback = DispatchWorkItem(flags: .barrier) { [weak self] in + guard let self, + self.pendingHandshakeRecoveryIDs[peerID] + == request.recoveryID, + self.handshakeRecoveryCallbackIDs[peerID] == callbackID else { + return + } + self.delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID) + self.handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) + let handler = self.onHandshakeRecoveryRequired + DispatchQueue.global().async { + handler?(request) + } + } + delayedHandshakeRecoveryWorkItems[peerID] = callback + managerQueue.asyncAfter( + deadline: .now() + max(0, delay), + execute: callback + ) + } + + private func consumeHandshakeRecoveryLocked( + _ request: NoiseHandshakeRecoveryRequest + ) { + let peerID = request.peerID + guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { + return + } + delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() + handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) + pendingHandshakeRecoveryIDs.removeValue(forKey: peerID) + } + + private func cancelDelayedHandshakeRecoveryLocked(for peerID: PeerID) { + delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() + handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) + pendingHandshakeRecoveryIDs.removeValue(forKey: peerID) } /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are @@ -563,14 +1203,11 @@ final class NoiseSessionManager { } } - func initiateRekey(for peerID: PeerID) throws -> Data { - let initiation = try initiateReconnectHandshake( + func initiateRekey(for peerID: PeerID) throws -> NoiseHandshakeInitiation { + try initiateReconnectHandshake( with: peerID, + notifyOnTimeout: true, authorize: {} ) - guard let payload = claimHandshakeInitiation(initiation, for: peerID) else { - throw NoiseSessionError.invalidState - } - return payload } } diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index 12b31176..19735ec2 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -112,6 +112,14 @@ final class BLENoisePacketHandler { didEstablishAuthenticatedSession: result.didEstablishAuthenticatedSession ) + } catch let managedFailure as NoiseManagedHandshakeFailure { + SecureLogger.error( + "Failed to process handshake; manager owns recovery: \(managedFailure.underlying)" + ) + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } catch NoiseSessionError.peerIdentityMismatch { // The responder was already discarded by the session manager. // Do not let a spoofed claimed ID trigger a fresh outbound diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3a40005d..c982e640 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2937,14 +2937,21 @@ extension BLEService: CBCentralManagerDelegate { startScanning() case .poweredOff: - // Bluetooth was turned off - stop scanning and clean up connection state + // CoreBluetooth has already transitioned out of poweredOn. Do + // not issue stop/cancel commands now; they are rejected as API + // misuse. Retire our link state locally instead. SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) - central.stopScan() - // Mark all peripheral connections as disconnected (they are now invalid) let peripheralStates = linkStateStore.peripheralStates let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) for state in peripheralStates { - central.cancelPeripheralConnection(state.peripheral) + let peripheralID = state.peripheral.identifier.uuidString + collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.discardAll(for: peripheralID) + } + noiseAuthenticatedLinkOwners.removeValue( + forKey: .peripheral(peripheralID) + ) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) } _ = linkStateStore.clearPeripherals() // Notify UI of disconnections @@ -2957,7 +2964,6 @@ extension BLEService: CBCentralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - central.stopScan() _ = linkStateStore.clearPeripherals() case .unsupported: @@ -3849,8 +3855,19 @@ extension BLEService: CBPeripheralManagerDelegate { case .poweredOff: // Bluetooth was turned off - clean up peripheral state SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) - peripheral.stopAdvertising() // Clear subscribed centrals (they are now invalid) + let centralSnapshot = linkStateStore.subscribedCentralSnapshot + for central in centralSnapshot.centrals { + let centralID = central.identifier.uuidString + noiseAuthenticatedLinkOwners.removeValue( + forKey: .central(centralID) + ) + noiseReconnectPolicy.endLinkEpoch(.central(centralID)) + } + collectionsQueue.sync(flags: .barrier) { + pendingNotifications.removeAll() + pendingWriteBuffers.removeAll() + } let centralPeerIDs = linkStateStore.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -3864,7 +3881,6 @@ extension BLEService: CBPeripheralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) - peripheral.stopAdvertising() _ = linkStateStore.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -4641,13 +4657,71 @@ extension BLEService { ) } } - service.onRekeyHandshakeReady = { [weak self] peerID, message in - self?.messageQueue.async { [weak self] in - guard let self else { return } + service.onRekeyHandshakeReady = { + [weak self, weak service] peerID, initiation in + self?.messageQueue.async(flags: .barrier) { + [weak self, weak service] in + guard let self, + let service, + self.noiseService === service else { + return + } self.noteNoiseSessionCleared(for: peerID) + guard let message = service.claimHandshakeInitiation( + initiation, + for: peerID + ) else { + return + } self.broadcastNoiseHandshake(message, to: peerID) } } + service.onHandshakeRecoveryRequired = { + [weak self, weak service] request in + guard let self, let service else { return } + self.messageQueue.async(flags: .barrier) { + [weak self, weak service] in + guard let self, + let service, + self.noiseService === service else { + return + } + let peerID = request.peerID + guard self.isPeerReachable(peerID) else { + service.cancelHandshakeRecovery(request) + return + } + + do { + guard let preparation = + try service.prepareHandshakeRecovery(request) else { + return + } + switch preparation { + case .ordinary(let initiation): + self.noteNoiseSessionCleared(for: peerID) + guard let handshakeData = + service.claimHandshakeInitiation( + initiation, + for: peerID + ) else { + return + } + self.broadcastNoiseHandshake( + handshakeData, + to: peerID + ) + case .transferred: + return + } + } catch { + SecureLogger.error( + "Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)", + category: .session + ) + } + } + } service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in guard let self, let service else { return } self.messageQueue.async { [weak self, weak service] in @@ -5922,12 +5996,27 @@ extension BLEService { } private func initiateNoiseHandshake(with peerID: PeerID) { - // Use NoiseEncryptionService for handshake - guard !noiseService.hasSession(with: peerID) else { return } - + let service = noiseService do { - let handshakeData = try noiseService.initiateHandshake(with: peerID) - broadcastNoiseHandshake(handshakeData, to: peerID) + guard let initiation = try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) else { + return + } + messageQueue.async(flags: .barrier) { + [weak self, weak service] in + guard let self, + let service, + self.noiseService === service, + let handshakeData = service.claimHandshakeInitiation( + initiation, + for: peerID + ) else { + return + } + self.broadcastNoiseHandshake(handshakeData, to: peerID) + } } catch { SecureLogger.error("Failed to initiate handshake: \(error)") } @@ -5953,13 +6042,18 @@ extension BLEService { private func initiateNoiseReconnectHandshake(with peerID: PeerID) { let service = noiseService do { - let initiation = try service.initiateReconnectHandshake(with: peerID) - noteNoiseSessionCleared(for: peerID) + let initiation = try service.initiateReconnectHandshake( + with: peerID, + retryOnTimeout: true + ) messageQueue.async(flags: .barrier) { [weak self, weak service] in guard let self, let service, - self.noiseService === service, - let handshakeData = service.claimHandshakeInitiation( + self.noiseService === service else { + return + } + self.noteNoiseSessionCleared(for: peerID) + guard let handshakeData = service.claimHandshakeInitiation( initiation, for: peerID ) else { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 3b73d494..c1a1e878 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -184,11 +184,13 @@ final class NoiseEncryptionService { private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = [] var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake - /// Automatic rekey removed the old session and produced XX message 1. - /// The transport must clear session-scoped state and put these exact bytes - /// on the wire; merely reporting "handshake required" strands the partial - /// initiator session because a second initiate call sees it already exists. - var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)? + /// Automatic rekey prepared XX message 1. The transport must claim the + /// exact attempt at its actual BLE handoff; a crossed inbound initiation + /// can invalidate the token before that point. + var onRekeyHandshakeReady: + ((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)? + var onHandshakeRecoveryRequired: + ((_ request: NoiseHandshakeRecoveryRequest) -> Void)? /// An unauthenticated reconnect attempt failed or timed out and the /// receive-only rollback session became the active transport again. /// Transport queues must be drained for this exact restored generation. @@ -225,8 +227,14 @@ final class NoiseEncryptionService { init( keychain: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, ordinaryResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown ) { self.keychain = keychain self.localPrekeys = LocalPrekeyStore(keychain: keychain) @@ -320,7 +328,13 @@ final class NoiseEncryptionService { self.sessionManager = NoiseSessionManager( localStaticKey: staticIdentityKey, keychain: keychain, - ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: + recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: + ordinaryReconnectRollbackCooldown ) // Set up session callbacks @@ -334,6 +348,9 @@ final class NoiseEncryptionService { sessionManager.onSessionRestored = { [weak self] peerID, generation in self?.onSessionRestoredWithGeneration?(peerID, generation) } + sessionManager.onHandshakeRecoveryRequired = { [weak self] request in + self?.onHandshakeRecoveryRequired?(request) + } // Start session maintenance timer startRekeyTimer() @@ -698,11 +715,41 @@ final class NoiseEncryptionService { return handshakeData } + /// Atomically admits and prepares one initial ordinary handshake. Returns + /// nil when another discovery callback already created a session. + func initiateHandshakeIfNeeded( + with peerID: PeerID, + retryOnTimeout: Bool = false + ) throws -> NoiseHandshakeInitiation? { + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + guard let initiation = try sessionManager.initiateHandshakeIfAbsent( + with: peerID, + notifyOnTimeout: retryOnTimeout, + authorize: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning( + .authenticationFailed(peerID: "Rate limited: \(peerID)") + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) else { + return nil + } + SecureLogger.info(.handshakeStarted(peerID: peerID.id)) + return initiation + } + /// Atomically prepares an ordinary reconnect for a peer whose cached /// transport belongs to an earlier physical link. Failed authorization or /// handshake setup preserves the established session. func initiateReconnectHandshake( - with peerID: PeerID + with peerID: PeerID, + retryOnTimeout: Bool = false ) throws -> NoiseHandshakeInitiation { guard peerID.isValid else { SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) @@ -711,6 +758,7 @@ final class NoiseEncryptionService { return try sessionManager.initiateReconnectHandshake( with: peerID, + notifyOnTimeout: retryOnTimeout, authorize: { [rateLimiter] in guard rateLimiter.allowHandshake(from: peerID) else { SecureLogger.warning( @@ -722,6 +770,28 @@ final class NoiseEncryptionService { ) } + func prepareHandshakeRecovery( + _ request: NoiseHandshakeRecoveryRequest + ) throws -> NoiseHandshakeRecoveryPreparation? { + try sessionManager.prepareHandshakeRecovery( + request, + authorizeAttempt: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: request.peerID) else { + SecureLogger.warning( + .authenticationFailed( + peerID: "Rate limited: \(request.peerID)" + ) + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) + } + + func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) { + sessionManager.cancelHandshakeRecovery(request) + } + func claimHandshakeInitiation( _ initiation: NoiseHandshakeInitiation, for peerID: PeerID @@ -991,9 +1061,9 @@ final class NoiseEncryptionService { } private func initiateAutomaticRekey(for peerID: PeerID) throws { - let handshakeMessage = try sessionManager.initiateRekey(for: peerID) + let initiation = try sessionManager.initiateRekey(for: peerID) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) - onRekeyHandshakeReady?(peerID, handshakeMessage) + onRekeyHandshakeReady?(peerID, initiation) onHandshakeRequired?(peerID) } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 1f25c86d..85f8494a 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -549,20 +549,26 @@ struct BLEServiceCoreTests { // Preserve a working victim session while an unauthenticated // replacement candidate arrives on a newly bound physical link. - let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) + // Establish BLE as responder so the replacement candidate below is + // not coalesced by the initiator-completion grace path. + let message1 = try victim.initiateHandshake(with: ble.myPeerID) let message2 = try #require( - try victim.processHandshakeMessage(from: ble.myPeerID, message: message1) - ) - let message3 = try #require( try ble._test_noiseProcessHandshakeMessage( from: victimPeerID, + message: message1 + ) + ) + let message3 = try #require( + try victim.processHandshakeMessage( + from: ble.myPeerID, message: message2 ) ) - _ = try victim.processHandshakeMessage( - from: ble.myPeerID, + _ = try ble._test_noiseProcessHandshakeMessage( + from: victimPeerID, message: message3 ) + await ble._test_drainNoiseMessagePipeline() #expect(ble.canDeliverSecurely(to: victimPeerID)) let centralUUID = "central-replacement-xx-message-one" diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index f1e85e4a..7f3eb01a 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -5,7 +5,7 @@ import BitFoundation @testable import bitchat -@Suite("Noise Coverage Tests") +@Suite("Noise Coverage Tests", .serialized) struct NoiseCoverageTests { private let keychain = MockKeychain() private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey() @@ -633,8 +633,16 @@ struct NoiseCoverageTests { ) let replacementSession = try #require(manager.getSession(for: alicePeerID)) - #expect(replacementResponse != nil) - #expect(replacementSession !== restartedSession) + let localPeerID = PeerID( + publicKey: aliceStaticKey.publicKey.rawRepresentation + ) + if localPeerID < alicePeerID { + #expect(replacementResponse == nil) + #expect(replacementSession === restartedSession) + } else { + #expect(replacementResponse != nil) + #expect(replacementSession !== restartedSession) + } let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) @@ -654,7 +662,13 @@ struct NoiseCoverageTests { try aliceManager.initiateHandshake(with: alicePeerID) } - let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID) + let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID) + let rekeyHandshake = try #require( + aliceManager.claimHandshakeInitiation( + rekeyInitiation, + for: alicePeerID + ) + ) #expect(!rekeyHandshake.isEmpty) let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID)) @@ -667,6 +681,7 @@ struct NoiseCoverageTests { let aliceManager = NoiseSessionManager( localStaticKey: aliceStaticKey, keychain: keychain, + recentInitiatorCompletionGracePeriod: 0, sessionFactory: { peerID, role in BlockingDecryptNoiseSession( peerID: peerID, diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 0457f71a..7384613c 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -198,6 +198,24 @@ struct BLENoisePacketHandlerTests { #expect(recorder.broadcastPackets.isEmpty) } + @Test + func managedHandshakeFailureDoesNotStartASecondRecovery() { + let recorder = Recorder() + recorder.handshakeResult = .failure( + NoiseManagedHandshakeFailure(underlying: TestError()) + ) + recorder.hasSession = false + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket( + recipientID: Data(hexString: localPeerID.id) + ) + + #expect(!handler.handleHandshake(packet, from: remotePeerID)) + #expect(recorder.hasSessionQueries.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + #expect(recorder.broadcastPackets.isEmpty) + } + // MARK: Encrypted @Test diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 3520cb1f..3769fe64 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -3,7 +3,7 @@ import Testing import BitFoundation @testable import bitchat -@Suite("NoiseEncryptionService Tests") +@Suite("NoiseEncryptionService Tests", .serialized) struct NoiseEncryptionServiceTests { @Test("Encryption status accessors cover all cases") @@ -267,10 +267,10 @@ struct NoiseEncryptionServiceTests { #expect(leaseRan) var emittedPeerID: PeerID? - var emittedMessage: Data? - alice.onRekeyHandshakeReady = { peerID, message in + var emittedInitiation: NoiseHandshakeInitiation? + alice.onRekeyHandshakeReady = { peerID, initiation in emittedPeerID = peerID - emittedMessage = message + emittedInitiation = initiation } try alice._test_initiateAutomaticRekey(for: bobPeerID) @@ -286,7 +286,10 @@ struct NoiseEncryptionServiceTests { } #expect(staleLease == nil) #expect(!leaseRan) - let message1 = try #require(emittedMessage) + let initiation = try #require(emittedInitiation) + let message1 = try #require( + alice.claimHandshakeInitiation(initiation, for: bobPeerID) + ) #expect(!message1.isEmpty) #expect(alice.hasSession(with: bobPeerID)) #expect(!alice.hasEstablishedSession(with: bobPeerID)) @@ -353,6 +356,700 @@ struct NoiseEncryptionServiceTests { #expect(decrypted == typedPayload) } + @Test("Concurrent BLE starts preserve one ordinary attempt") + func duplicateHandshakeIfNeededPreservesFirstAttempt() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + let starts = HandshakeInitiationRecorder() + + DispatchQueue.concurrentPerform(iterations: 20) { _ in + do { + starts.record( + try alice.initiateHandshakeIfNeeded(with: bobPeerID) + ) + } catch { + starts.record(error: error) + } + } + + #expect(starts.errorCount == 0) + let attempt = try #require(starts.initiations.first) + #expect(starts.initiations.count == 1) + let message1 = try #require( + alice.claimHandshakeInitiation(attempt, for: bobPeerID) + ) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + let ciphertext = try alice.encrypt( + Data("one atomic start".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(ciphertext, from: alicePeerID) + == Data("one atomic start".utf8) + ) + } + + @Test("Restarted peer establishes against a retained ordinary session") + func restartedPeerCompletesRetainedRemoteRehandshake() throws { + let aliceKeychain = MockKeychain() + let alice = NoiseEncryptionService(keychain: aliceKeychain) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + + let restartedAlice = NoiseEncryptionService(keychain: aliceKeychain) + let attempt = try #require( + try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) + ) + #expect( + try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) + == nil + ) + let message1 = try #require( + restartedAlice.claimHandshakeInitiation( + attempt, + for: bobPeerID + ) + ) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try restartedAlice.processHandshakeMessage( + from: bobPeerID, + message: message2 + ) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + let forward = try restartedAlice.encrypt( + Data("after restart".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(forward, from: alicePeerID) + == Data("after restart".utf8) + ) + } + + @Test("Crossed ordinary initiations choose one deterministic initiator") + func crossedOrdinaryInitiationsResolveDeterministically() throws { + let endpoints = orderedServices() + let lowerAttempt = try #require( + try endpoints.lower.initiateHandshakeIfNeeded( + with: endpoints.higherPeerID + ) + ) + let higherAttempt = try #require( + try endpoints.higher.initiateHandshakeIfNeeded( + with: endpoints.lowerPeerID + ) + ) + let lowerMessage1 = try #require( + endpoints.lower.claimHandshakeInitiation( + lowerAttempt, + for: endpoints.higherPeerID + ) + ) + let higherMessage1 = try #require( + endpoints.higher.claimHandshakeInitiation( + higherAttempt, + for: endpoints.lowerPeerID + ) + ) + + #expect( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: higherMessage1 + ) == nil + ) + let message2 = try #require( + try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: lowerMessage1 + ) + ) + let message3 = try #require( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: message2 + ) + ) + _ = try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: message3 + ) + + let ciphertext = try endpoints.lower.encrypt( + Data("crossed".utf8), + for: endpoints.higherPeerID + ) + #expect( + try endpoints.higher.decrypt( + ciphertext, + from: endpoints.lowerPeerID + ) == Data("crossed".utf8) + ) + } + + @Test("Delayed losing message one cannot replace the fresh winner") + func delayedCrossedInitiationIsSuppressed() throws { + let endpoints = orderedServices() + let lowerAttempt = try #require( + try endpoints.lower.initiateHandshakeIfNeeded( + with: endpoints.higherPeerID + ) + ) + let higherAttempt = try #require( + try endpoints.higher.initiateHandshakeIfNeeded( + with: endpoints.lowerPeerID + ) + ) + let lowerMessage1 = try #require( + endpoints.lower.claimHandshakeInitiation( + lowerAttempt, + for: endpoints.higherPeerID + ) + ) + let delayedHigherMessage1 = try #require( + endpoints.higher.claimHandshakeInitiation( + higherAttempt, + for: endpoints.lowerPeerID + ) + ) + + let message2 = try #require( + try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: lowerMessage1 + ) + ) + let message3 = try #require( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: message2 + ) + ) + #expect( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: delayedHigherMessage1 + ) == nil + ) + _ = try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: message3 + ) + + let ciphertext = try endpoints.higher.encrypt( + Data("winner intact".utf8), + for: endpoints.lowerPeerID + ) + #expect( + try endpoints.lower.decrypt( + ciphertext, + from: endpoints.higherPeerID + ) == Data("winner intact".utf8) + ) + } + + @Test("Automatic rekey token dies if an inbound initiation wins first") + func automaticRekeyClaimsOnlyAtTransportHandoff() throws { + let endpoints = orderedServices() + try establishSessions( + alice: endpoints.lower, + bob: endpoints.higher + ) + + var preparedRekey: NoiseHandshakeInitiation? + endpoints.higher.onRekeyHandshakeReady = { _, initiation in + preparedRekey = initiation + } + try endpoints.higher._test_initiateAutomaticRekey( + for: endpoints.lowerPeerID + ) + let staleRekey = try #require(preparedRekey) + + let winningAttempt = try endpoints.lower.initiateReconnectHandshake( + with: endpoints.higherPeerID + ) + let winningMessage1 = try #require( + endpoints.lower.claimHandshakeInitiation( + winningAttempt, + for: endpoints.higherPeerID + ) + ) + let message2 = try #require( + try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: winningMessage1 + ) + ) + #expect( + endpoints.higher.claimHandshakeInitiation( + staleRekey, + for: endpoints.lowerPeerID + ) == nil + ) + let message3 = try #require( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: message2 + ) + ) + _ = try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: message3 + ) + #expect( + endpoints.higher.hasEstablishedSession( + with: endpoints.lowerPeerID + ) + ) + } + + @Test("Ordinary timeout produces exactly one bounded retry") + func ordinaryInitiationTimeoutIsBounded() async throws { + let service = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.03 + ) + let peerID = PeerID(str: "1021324354657687") + let recorder = HandshakeStartRecorder() + service.onHandshakeRecoveryRequired = { [weak service] request in + guard let service else { return } + do { + let payload = try claimPreparedRecoveryPayload( + service, + request: request + ) + recorder.recordTimeout() + recorder.record(message: payload) + } catch { + recorder.record(error: error) + } + } + + let first = try #require( + try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) + ) + #expect( + service.claimHandshakeInitiation(first, for: peerID) != nil + ) + let retried = await TestHelpers.waitUntil( + { recorder.messages.count == 1 }, + timeout: 1 + ) + #expect(retried) + let retryExpired = await TestHelpers.waitUntil( + { !service.hasSession(with: peerID) }, + timeout: 1 + ) + #expect(retryExpired) + #expect(recorder.timeoutCount == 1) + #expect(recorder.errorCount == 0) + } + + @Test("Claim gives an attempt a full on-wire timeout window") + func handshakeClaimRearmsDeadline() async throws { + let service = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.08 + ) + let peerID = PeerID(str: "1021324354657687") + let recorder = HandshakeStartRecorder() + service.onHandshakeRecoveryRequired = { [weak service] request in + service?.cancelHandshakeRecovery(request) + recorder.recordTimeout() + } + + let attempt = try #require( + try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) + ) + try? await Task.sleep(nanoseconds: 50_000_000) + #expect( + service.claimHandshakeInitiation(attempt, for: peerID) + == attempt.payload + ) + try? await Task.sleep(nanoseconds: 45_000_000) + #expect(service.hasSession(with: peerID)) + #expect(recorder.timeoutCount == 0) + let expired = await TestHelpers.waitUntil( + { recorder.timeoutCount == 1 }, + timeout: 1 + ) + #expect(expired) + } + + @Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown") + func pacedMessageOneCannotHoldOutboundPaused() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryResponderHandshakeTimeout: 0.06, + ordinaryReconnectRollbackCooldown: 0.3 + ) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + let recovery = HandshakeStartRecorder() + bob.onHandshakeRecoveryRequired = { [weak bob] request in + bob?.cancelHandshakeRecovery(request) + recovery.recordTimeout() + } + try establishSessions(alice: alice, bob: bob) + + let spoofedMessage1 = try mallory.initiateHandshake(with: bobPeerID) + _ = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: spoofedMessage1 + ) + ) + try? await Task.sleep(nanoseconds: 35_000_000) + _ = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: spoofedMessage1 + ) + ) + + let restored = await TestHelpers.waitUntil( + { bob.hasEstablishedSession(with: alicePeerID) }, + timeout: 1 + ) + #expect(restored) + let callbackArrived = await TestHelpers.waitUntil( + { recovery.timeoutCount == 1 }, + timeout: 1 + ) + #expect(callbackArrived) + + // Still inside cooldown: the same unauthenticated initiation is + // coalesced without removing the restored outbound generation. + #expect( + try bob.processHandshakeMessage( + from: alicePeerID, + message: spoofedMessage1 + ) == nil + ) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let ciphertext = try alice.encrypt( + Data("not repaused".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(ciphertext, from: alicePeerID) + == Data("not repaused".utf8) + ) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(recovery.timeoutCount == 1) + } + + @Test("Lost reconnect message three restores then retries once") + func lostReconnectCompletionGetsOneLocalRetry() async throws { + let alice = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.04 + ) + let bob = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.04, + ordinaryResponderHandshakeTimeout: 0.04 + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + alice.clearSession(for: bobPeerID) + + let recovery = HandshakeStartRecorder() + bob.onHandshakeRecoveryRequired = { [weak bob] request in + guard let bob else { return } + do { + recovery.recordTimeout() + recovery.record( + message: try claimPreparedRecoveryPayload( + bob, + request: request + ) + ) + } catch { + recovery.record(error: error) + } + } + + let message1 = try alice.initiateHandshake(with: bobPeerID) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + _ = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + // Drop message 3. Bob restores its old receive-only transport and + // initiates one bounded convergence retry; drop that message 1 too. + let retryPrepared = await TestHelpers.waitUntil( + { recovery.messages.count == 1 }, + timeout: 1 + ) + #expect(retryPrepared) + let retryExpired = await TestHelpers.waitUntil( + { !bob.hasSession(with: alicePeerID) }, + timeout: 1 + ) + #expect(retryExpired) + #expect(recovery.timeoutCount == 1) + #expect(recovery.errorCount == 0) + } + + @Test("Deterministic responder recovers once from an always-yield peer") + func yieldedResponderRecoversFromLegacyDoubleYield() async throws { + let endpoints = orderedServices( + ordinaryHandshakeTimeout: 0.08, + ordinaryResponderHandshakeTimeout: 0.08 + ) + let modern = endpoints.higher + let legacy = endpoints.lower + let recovery = HandshakeStartRecorder() + modern.onHandshakeRecoveryRequired = { [weak modern] request in + guard let modern else { return } + do { + recovery.recordTimeout() + recovery.record( + message: try claimPreparedRecoveryPayload( + modern, + request: request + ) + ) + } catch { + recovery.record(error: error) + } + } + + let modernAttempt = try #require( + try modern.initiateHandshakeIfNeeded( + with: endpoints.lowerPeerID, + retryOnTimeout: true + ) + ) + let legacyAttempt = try #require( + try legacy.initiateHandshakeIfNeeded( + with: endpoints.higherPeerID + ) + ) + let modernMessage1 = try #require( + modern.claimHandshakeInitiation( + modernAttempt, + for: endpoints.lowerPeerID + ) + ) + let legacyMessage1 = try #require( + legacy.claimHandshakeInitiation( + legacyAttempt, + for: endpoints.higherPeerID + ) + ) + + let modernMessage2 = try #require( + try modern.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: legacyMessage1 + ) + ) + // Released peers yielded regardless of ID. Clearing their local + // initiator reproduces that role choice without changing wire bytes. + legacy.clearSession(for: endpoints.higherPeerID) + let legacyMessage2 = try #require( + try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: modernMessage1 + ) + ) + + do { + _ = try modern.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: legacyMessage2 + ) + Issue.record("Expected the crossed responder message to fail") + } catch is NoiseManagedHandshakeFailure { + // The manager owns the single retry. + } catch { + Issue.record("Unexpected managed failure: \(error)") + } + do { + _ = try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: modernMessage2 + ) + Issue.record("Expected the legacy responder message to fail") + } catch { + // Expected; this side did not own retry intent. + } + + let didRecover = await TestHelpers.waitUntil( + { recovery.messages.count == 1 }, + timeout: 1 + ) + #expect(didRecover) + let retryMessage1 = try #require(recovery.messages.first) + let retryMessage2 = try #require( + try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: retryMessage1 + ) + ) + let retryMessage3 = try #require( + try modern.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: retryMessage2 + ) + ) + _ = try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: retryMessage3 + ) + try? await Task.sleep(nanoseconds: 120_000_000) + #expect(recovery.timeoutCount == 1) + #expect(recovery.errorCount == 0) + let ciphertext = try modern.encrypt( + Data("legacy converged".utf8), + for: endpoints.lowerPeerID + ) + #expect( + try legacy.decrypt( + ciphertext, + from: endpoints.higherPeerID + ) == Data("legacy converged".utf8) + ) + } + + @Test("Immediate legacy restart during completion grace converges once") + func immediateLegacyRestartDuringCompletionGrace() async throws { + let firstKeychain = MockKeychain() + let secondKeychain = MockKeychain() + let first = NoiseEncryptionService( + keychain: firstKeychain, + recentInitiatorCompletionGracePeriod: 0.03 + ) + let second = NoiseEncryptionService( + keychain: secondKeychain, + recentInitiatorCompletionGracePeriod: 0.03 + ) + let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) + let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) + + let lower: NoiseEncryptionService + let higher: NoiseEncryptionService + let higherKeychain: MockKeychain + let lowerPeerID: PeerID + let higherPeerID: PeerID + if firstPeerID < secondPeerID { + lower = first + lowerPeerID = firstPeerID + higher = second + higherKeychain = secondKeychain + higherPeerID = secondPeerID + } else { + lower = second + lowerPeerID = secondPeerID + higher = first + higherKeychain = firstKeychain + higherPeerID = firstPeerID + } + try establishSessions(alice: lower, bob: higher) + + let restartedHigher = NoiseEncryptionService(keychain: higherKeychain) + let recovery = HandshakeStartRecorder() + lower.onHandshakeRecoveryRequired = { [weak lower] request in + guard let lower else { return } + do { + recovery.recordTimeout() + recovery.record( + message: try claimPreparedRecoveryPayload( + lower, + request: request + ) + ) + } catch { + recovery.record(error: error) + } + } + + let restartAttempt = try #require( + try restartedHigher.initiateHandshakeIfNeeded(with: lowerPeerID) + ) + let restartMessage1 = try #require( + restartedHigher.claimHandshakeInitiation( + restartAttempt, + for: lowerPeerID + ) + ) + #expect( + try lower.processHandshakeMessage( + from: higherPeerID, + message: restartMessage1 + ) == nil + ) + #expect( + try lower.processHandshakeMessage( + from: higherPeerID, + message: restartMessage1 + ) == nil + ) + #expect(lower.hasEstablishedSession(with: higherPeerID)) + + let requested = await TestHelpers.waitUntil( + { recovery.messages.count == 1 }, + timeout: 1 + ) + #expect(requested) + let retryMessage1 = try #require(recovery.messages.first) + let retryMessage2 = try #require( + try restartedHigher.processHandshakeMessage( + from: lowerPeerID, + message: retryMessage1 + ) + ) + let retryMessage3 = try #require( + try lower.processHandshakeMessage( + from: higherPeerID, + message: retryMessage2 + ) + ) + _ = try restartedHigher.processHandshakeMessage( + from: lowerPeerID, + message: retryMessage3 + ) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(recovery.timeoutCount == 1) + #expect(recovery.errorCount == 0) + let ciphertext = try restartedHigher.encrypt( + Data("restart converged".utf8), + for: lowerPeerID + ) + #expect( + try lower.decrypt(ciphertext, from: higherPeerID) + == Data("restart converged".utf8) + ) + } + @Test("Atomic reconnect retires old sending keys before message one") func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) @@ -453,6 +1150,31 @@ struct NoiseEncryptionServiceTests { ) } + @Test("Malformed handshake bytes cannot tear down an established session") + func establishedSessionIgnoresNonInitialHandshakeGarbage() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + + #expect( + try bob.processHandshakeMessage( + from: alicePeerID, + message: Data(repeating: 0xA5, count: 31) + ) == nil + ) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let ciphertext = try alice.encrypt( + Data("session survived".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(ciphertext, from: alicePeerID) + == Data("session survived".utf8) + ) + } + @Test("Forged reconnect restores the quarantined transport") func forgedReconnectRestoresQuarantinedTransport() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) @@ -661,6 +1383,56 @@ struct NoiseEncryptionServiceTests { } } +private func orderedServices( + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout +) -> ( + lower: NoiseEncryptionService, + lowerPeerID: PeerID, + higher: NoiseEncryptionService, + higherPeerID: PeerID +) { + let first = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout + ) + let second = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout + ) + let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) + let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) + if firstPeerID < secondPeerID { + return (first, firstPeerID, second, secondPeerID) + } + return (second, secondPeerID, first, firstPeerID) +} + +private func claimPreparedRecoveryPayload( + _ service: NoiseEncryptionService, + request: NoiseHandshakeRecoveryRequest +) throws -> Data? { + guard let preparation = + try service.prepareHandshakeRecovery(request) else { + return nil + } + switch preparation { + case .ordinary(let initiation): + return service.claimHandshakeInitiation( + initiation, + for: request.peerID + ) + case .transferred: + return nil + } +} + private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] @@ -696,3 +1468,78 @@ private final class AuthenticationRecorder: @unchecked Sendable { return generationEntries.last { $0.0 == peerID }?.1 } } + +private final class HandshakeInitiationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedInitiations: [NoiseHandshakeInitiation] = [] + private var storedErrorCount = 0 + + var initiations: [NoiseHandshakeInitiation] { + lock.lock() + defer { lock.unlock() } + return storedInitiations + } + + var errorCount: Int { + lock.lock() + defer { lock.unlock() } + return storedErrorCount + } + + func record(_ initiation: NoiseHandshakeInitiation?) { + guard let initiation else { return } + lock.lock() + storedInitiations.append(initiation) + lock.unlock() + } + + func record(error _: Error) { + lock.lock() + storedErrorCount += 1 + lock.unlock() + } +} + +private final class HandshakeStartRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedMessages: [Data] = [] + private var storedErrorCount = 0 + private var storedTimeoutCount = 0 + + var messages: [Data] { + lock.lock() + defer { lock.unlock() } + return storedMessages + } + + var errorCount: Int { + lock.lock() + defer { lock.unlock() } + return storedErrorCount + } + + var timeoutCount: Int { + lock.lock() + defer { lock.unlock() } + return storedTimeoutCount + } + + func record(message: Data?) { + guard let message else { return } + lock.lock() + storedMessages.append(message) + lock.unlock() + } + + func record(error _: Error) { + lock.lock() + storedErrorCount += 1 + lock.unlock() + } + + func recordTimeout() { + lock.lock() + storedTimeoutCount += 1 + lock.unlock() + } +}