From 797494e075eec2b03c1509710092e4f7c6561a1b Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 18:41:15 +0200 Subject: [PATCH] Fix cached Noise reconnects atomically --- bitchat/Noise/NoiseSecurityConstants.swift | 4 + bitchat/Noise/NoiseSessionManager.swift | 345 ++++++++++++++---- .../Services/BLE/BLENoisePacketHandler.swift | 6 +- .../BLE/BLENoiseReconnectPolicy.swift | 40 ++ bitchat/Services/BLE/BLEService.swift | 141 ++++++- bitchat/Services/NoiseEncryptionService.swift | 56 ++- bitchatTests/BLEServiceCoreTests.swift | 166 ++++++++- .../BLENoiseReconnectPolicyTests.swift | 115 ++++++ .../NoiseEncryptionServiceTests.swift | 240 +++++++++++- .../BitFoundation/PeerCapabilities.swift | 5 + .../PeerCapabilitiesTests.swift | 4 + 11 files changed, 1011 insertions(+), 111 deletions(-) create mode 100644 bitchat/Services/BLE/BLENoiseReconnectPolicy.swift create mode 100644 bitchatTests/Services/BLENoiseReconnectPolicyTests.swift diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index b19ff854..95d358f2 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -36,6 +36,10 @@ enum NoiseSecurityConstants { // Noise XX message 1 contains only the initiator's 32-byte ephemeral key. static let xxInitialMessageSize = 32 + + // 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 // Session timeout - sessions older than this should be renegotiated static let sessionTimeout: TimeInterval = 86400 // 24 hours diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index a6279bce..8bf27a0e 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -16,25 +16,50 @@ struct NoiseHandshakeProcessingResult { let didEstablishAuthenticatedSession: Bool } +struct NoiseHandshakeInitiation: Equatable, Sendable { + let payload: Data + let attemptID: UUID +} + final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] /// Opaque identity for each exact entry in `sessions`. The generation is /// created and removed under the same barrier as the session itself, so a /// caller can never authenticate data with one session and lease another. private var sessionGenerations: [PeerID: UUID] = [:] - /// A responder rehandshake must not evict a working transport session - /// before the candidate proves that its authenticated static key belongs - /// to the claimed wire ID. Candidates therefore live outside `sessions` - /// until the XX handshake completes and the binding is validated. - private var responderCandidates: [PeerID: NoiseSession] = [:] + /// 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 struct QuarantinedTransport { + let session: NoiseSession + let generation: UUID + /// Duplicate message 1 packets replace the incomplete responder but + /// never extend the original rollback window indefinitely. + let rollbackDeadline: DispatchTime + } + /// An unauthenticated inbound message 1 cannot keep old sending keys live, + /// but it also must not permanently destroy a victim session. The old + /// transport remains receive-only while the ordinary responder proves the + /// claimed static identity, then is discarded on success or restored on + /// bounded failure. + private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:] + private var quarantinedResponderTimeouts: [PeerID: DispatchWorkItem] = [:] private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession + private let ordinaryResponderHandshakeTimeout: 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)? - init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { + init( + localStaticKey: Curve25519.KeyAgreement.PrivateKey, + keychain: KeychainManagerProtocol, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + ) { + self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout self.sessionFactory = { peerID, role in SecureNoiseSession( peerID: peerID, @@ -49,8 +74,11 @@ final class NoiseSessionManager { init( localStaticKey _: Curve25519.KeyAgreement.PrivateKey, keychain _: KeychainManagerProtocol, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession ) { + self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout self.sessionFactory = sessionFactory } #endif @@ -65,27 +93,38 @@ final class NoiseSessionManager { func removeSession(for peerID: PeerID) { managerQueue.sync(flags: .barrier) { - if let session = sessions.removeValue(forKey: peerID) { - session.reset() // Clear sensitive data before removing - } - sessionGenerations.removeValue(forKey: peerID) - if let candidate = responderCandidates.removeValue(forKey: peerID) { - candidate.reset() - } + removeSessionLocked(for: peerID) } } + private func removeSessionLocked(for peerID: PeerID) { + if let session = sessions.removeValue(forKey: peerID) { + session.reset() // Clear sensitive data before removing + } + if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { + quarantined.session.reset() + } + quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel() + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + } + func removeAllSessions() { managerQueue.sync(flags: .barrier) { for (_, session) in sessions { session.reset() } - for (_, candidate) in responderCandidates { - candidate.reset() + for (_, quarantined) in quarantinedTransports { + quarantined.session.reset() + } + for (_, timeout) in quarantinedResponderTimeouts { + timeout.cancel() } sessions.removeAll() sessionGenerations.removeAll() - responderCandidates.removeAll() + ordinaryInitiationIDs.removeAll() + quarantinedTransports.removeAll() + quarantinedResponderTimeouts.removeAll() } } @@ -103,6 +142,7 @@ final class NoiseSessionManager { if let existingSession = sessions[peerID], !existingSession.isEstablished() { _ = sessions.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) existingSession.reset() } @@ -118,12 +158,73 @@ final class NoiseSessionManager { // Clean up failed session _ = sessions.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) 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, + authorize: () throws -> Void + ) throws -> NoiseHandshakeInitiation { + try managerQueue.sync(flags: .barrier) { + guard let established = sessions[peerID], + established.isEstablished() else { + throw NoiseSessionError.notEstablished + } + try authorize() + + let next = sessionFactory(peerID, .initiator) + let payload: Data + do { + payload = try next.startHandshake() + } catch { + next.reset() + SecureLogger.error( + .handshakeFailed( + peerID: peerID.id, + error: error.localizedDescription + ) + ) + throw error + } + + removeSessionLocked(for: peerID) + let attemptID = UUID() + sessions[peerID] = next + sessionGenerations[peerID] = UUID() + ordinaryInitiationIDs[peerID] = attemptID + return NoiseHandshakeInitiation( + payload: payload, + attemptID: attemptID + ) + } + } + + /// Claims a prepared message 1 exactly once. A crossed inbound initiation + /// that already made this peer a responder invalidates the token. + func claimHandshakeInitiation( + _ initiation: NoiseHandshakeInitiation, + for peerID: PeerID + ) -> Data? { + managerQueue.sync(flags: .barrier) { + guard ordinaryInitiationIDs[peerID] == initiation.attemptID, + let session = sessions[peerID], + session.role == .initiator, + session.getState() == .handshaking else { + return nil + } + ordinaryInitiationIDs.removeValue(forKey: peerID) + return initiation.payload + } + } func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { try handleIncomingHandshakeWithResult( @@ -153,52 +254,54 @@ final class NoiseSessionManager { )? ) = try managerQueue.sync(flags: .barrier) { let session: NoiseSession - let isReplacementCandidate: Bool - if let candidate = responderCandidates[peerID] { - // A fresh XX message 1 supersedes an incomplete candidate, - // but never the established session it is trying to replace. - if message.count == NoiseSecurityConstants.xxInitialMessageSize { - candidate.reset() - let replacement = sessionFactory(peerID, .responder) - responderCandidates[peerID] = replacement - session = replacement - } else { - session = candidate - } - isReplacementCandidate = true - } else if let existing = sessions[peerID] { - if existing.isEstablished() { - SecureLogger.info( - "Validating replacement handshake from \(peerID) while preserving the established session", - category: .session - ) - let candidate = sessionFactory(peerID, .responder) - responderCandidates[peerID] = candidate - session = candidate - isReplacementCandidate = true - } else if existing.getState() == .handshaking, - message.count == NoiseSecurityConstants.xxInitialMessageSize { - // No established transport state exists to preserve. A - // fresh initiation replaces the incomplete handshake. + if let existing = sessions[peerID], + existing.isEstablished() + || message.count + == NoiseSecurityConstants.xxInitialMessageSize { + 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) - existing.reset() - let replacement = sessionFactory(peerID, .responder) - sessions[peerID] = replacement - sessionGenerations[peerID] = UUID() - session = replacement - isReplacementCandidate = false + ordinaryInitiationIDs.removeValue(forKey: peerID) + if let prior = quarantinedTransports.updateValue( + QuarantinedTransport( + session: existing, + generation: generation, + rollbackDeadline: .now() + + ordinaryResponderHandshakeTimeout + ), + forKey: peerID + ) { + prior.session.reset() + } } else { - session = existing - isReplacementCandidate = false + _ = 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 + ) + } + } else if let existing = sessions[peerID] { + session = existing } else { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession sessionGenerations[peerID] = UUID() session = newSession - isReplacementCandidate = false } // Process the handshake message within the synchronized block @@ -218,14 +321,15 @@ final class NoiseSessionManager { throw NoiseSessionError.peerIdentityMismatch } - if isReplacementCandidate { - _ = responderCandidates.removeValue(forKey: peerID) - let previous = sessions.updateValue(session, forKey: peerID) - sessionGenerations[peerID] = UUID() - if let previous, previous !== session { - previous.reset() - } + if let quarantined = quarantinedTransports.removeValue( + forKey: peerID + ) { + quarantinedResponderTimeouts.removeValue( + forKey: peerID + )?.cancel() + quarantined.session.reset() } + ordinaryInitiationIDs.removeValue(forKey: peerID) guard let generation = sessionGenerations[peerID] else { throw NoiseEncryptionError.sessionNotEstablished } @@ -234,23 +338,28 @@ final class NoiseSessionManager { return (response, establishedSession) } catch { - // A failed candidate is discarded without touching the - // established session. Ordinary failed handshakes retain the - // historical cleanup behavior. - if isReplacementCandidate { - if let storedCandidate = responderCandidates[peerID], - storedCandidate === session { - _ = responderCandidates.removeValue(forKey: peerID) - } - } else if let storedSession = sessions[peerID], - storedSession === session { + if let storedSession = sessions[peerID], + storedSession === session { _ = sessions.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID) } + ordinaryInitiationIDs.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 + } else { + restoredGeneration = nil + } // Schedule callback outside the synchronized block to prevent deadlock DispatchQueue.global().async { [weak self] in + if let restoredGeneration { + self?.onSessionRestored?(peerID, restoredGeneration) + } self?.onSessionFailed?(peerID, error) } @@ -268,6 +377,47 @@ final class NoiseSessionManager { ) } + private func scheduleQuarantinedResponderTimeoutLocked( + _ responder: NoiseSession, + for peerID: PeerID + ) { + quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel() + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in + guard let self, + let responder, + let current = self.sessions[peerID], + current === responder, + current.role == .responder, + current.getState() == .handshaking, + let quarantined = self.quarantinedTransports.removeValue( + forKey: peerID + ) 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 + SecureLogger.debug( + "Ordinary responder handshake with \(peerID) timed out; restored quarantined transport", + category: .session + ) + DispatchQueue.global().async { [weak self] in + self?.onSessionRestored?(peerID, quarantined.generation) + } + } + quarantinedResponderTimeouts[peerID] = timeout + guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else { + quarantinedResponderTimeouts.removeValue(forKey: peerID) + return + } + managerQueue.asyncAfter(deadline: deadline, execute: timeout) + } + /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are /// also accepted by internal callers when they exactly match the static /// key. Non-wire identifiers remain available to protocol test harnesses; @@ -299,7 +449,7 @@ final class NoiseSessionManager { /// Encrypts only if `expected` still names the current established entry. /// A rekey between capability proof and media encryption therefore fails - /// closed instead of sending on an unproven replacement session. + /// closed instead of sending on an unproven reconnect session. func encrypt( _ plaintext: Data, for peerID: PeerID, @@ -334,19 +484,49 @@ final class NoiseSessionManager { from peerID: PeerID ) throws -> (plaintext: Data, sessionGeneration: UUID) { try managerQueue.sync { - guard let session = sessions[peerID] else { + if let session = sessions[peerID], + session.isEstablished(), + let generation = sessionGenerations[peerID] { + return (try session.decrypt(ciphertext), generation) + } + + // Quarantine is receive-only: old keys cannot encrypt, advertise + // an established generation, or authorize outbound state, but + // legitimate in-flight ciphertext from the retained peer may + // still advance and later resume on rollback. + if let responder = sessions[peerID], + responder.role == .responder, + responder.getState() == .handshaking, + let quarantined = quarantinedTransports[peerID] { + return ( + try quarantined.session.decrypt(ciphertext), + quarantined.generation + ) + } + + if sessions[peerID] == nil, quarantinedTransports[peerID] == nil { throw NoiseSessionError.sessionNotFound } - guard session.isEstablished(), - let generation = sessionGenerations[peerID] else { - throw NoiseEncryptionError.sessionNotEstablished + throw NoiseEncryptionError.sessionNotEstablished + } + } + + func hasReceiveSession(for peerID: PeerID) -> Bool { + managerQueue.sync { + if sessions[peerID]?.isEstablished() == true { + return true } - return (try session.decrypt(ciphertext), generation) + guard let responder = sessions[peerID], + responder.role == .responder, + responder.getState() == .handshaking else { + return false + } + return quarantinedTransports[peerID]?.session.isEstablished() == true } } /// Runs a state commit under a read lease for the exact established - /// session. Rekey, replacement, and removal all need the same barrier. + /// session. Rekey, reconnect, and removal all need the same barrier. func withCurrentSessionGeneration( for peerID: PeerID, expected: UUID, @@ -384,10 +564,13 @@ final class NoiseSessionManager { } func initiateRekey(for peerID: PeerID) throws -> Data { - // Remove old session - removeSession(for: peerID) - - // Initiate new handshake - return try initiateHandshake(with: peerID) + let initiation = try initiateReconnectHandshake( + with: peerID, + 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 736632e9..12b31176 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -70,8 +70,8 @@ final class BLENoisePacketHandler { } /// Returns true when the handshake message was processed successfully. - /// Callers use this to distinguish an authenticated replacement completion - /// from a rejected candidate while an older session remains established. + /// Callers use this to distinguish an authenticated reconnect completion + /// from a rejected ordinary responder while rollback state is restored. @discardableResult func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { handleHandshakeWithResult(packet, from: peerID).processed @@ -113,7 +113,7 @@ final class BLENoisePacketHandler { result.didEstablishAuthenticatedSession ) } catch NoiseSessionError.peerIdentityMismatch { - // The candidate was already discarded by the session manager. + // The responder was already discarded by the session manager. // Do not let a spoofed claimed ID trigger a fresh outbound // handshake or recreate state for the attacker-selected ID. SecureLogger.warning( diff --git a/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift b/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift new file mode 100644 index 00000000..99b748fb --- /dev/null +++ b/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch. +/// A live epoch may retry after the cooldown so a lost handshake cannot leave +/// the link permanently unauthenticated. +struct BLENoiseReconnectPolicy { + static let minimumRetryInterval: TimeInterval = 60 + + private var lastAttemptAt: [BLEIngressLinkID: Date] = [:] + + mutating func shouldRevalidate( + on link: BLEIngressLinkID, + hasEstablishedSession: Bool, + isNoiseAuthenticatedLink: Bool, + hasAuthenticatedPeerLink: Bool, + now: Date + ) -> Bool { + guard hasEstablishedSession, + !isNoiseAuthenticatedLink, + !hasAuthenticatedPeerLink else { + return false + } + if let previous = lastAttemptAt[link], + now.timeIntervalSince(previous) < Self.minimumRetryInterval { + return false + } + lastAttemptAt[link] = now + return true + } + + /// Link identifiers can be stable across CoreBluetooth reconnects, so a + /// disconnect explicitly starts a new epoch and permits one fresh attempt. + mutating func endLinkEpoch(_ link: BLEIngressLinkID) { + lastAttemptAt.removeValue(forKey: link) + } + + mutating func removeAll() { + lastAttemptAt.removeAll() + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index c67fb273..3a40005d 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -241,6 +241,7 @@ final class BLEService: NSObject { // that the session was established *on this current ingress link*, not // merely that some session exists for the claimed ID. bleQueue-owned. private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:] + private var noiseReconnectPolicy = BLENoiseReconnectPolicy() // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link // store): entries older than the cooldown are pruned on insert. @@ -311,6 +312,9 @@ final class BLEService: NSObject { /// May block in tests to hold the serial message queue immediately before /// the deferred private-media admission check. var _test_beforePrivateMediaDeferredSend: ((String) -> Void)? + /// May block announce handling after verified-link rebind work is queued. + /// Tests use this boundary to prove rebind and reconnect are serialized. + var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)? #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() @@ -764,6 +768,8 @@ final class BLEService: NSObject { bleQueue.sync { pendingWriteBuffers.removeAll() + noiseAuthenticatedLinkOwners.removeAll() + noiseReconnectPolicy.removeAll() connectionScheduler.reset() } disconnectNotifyDebouncer.removeAll() @@ -1061,6 +1067,7 @@ final class BLEService: NSObject { bleQueue.sync { linkStateStore.clearAll() noiseAuthenticatedLinkOwners.removeAll() + noiseReconnectPolicy.removeAll() connectionScheduler.reset() subscriptionAnnounceLimiter.removeAll() } @@ -2617,6 +2624,7 @@ final class BLEService: NSObject { } for link in departedLinks { noiseAuthenticatedLinkOwners.removeValue(forKey: link) + noiseReconnectPolicy.endLinkEpoch(link) } } _ = collectionsQueue.sync(flags: .barrier) { @@ -3097,6 +3105,7 @@ extension BLEService: CBCentralManagerDelegate { pendingPeripheralWrites.discardAll(for: peripheralID) } noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) // A duplicate link can drop while the peer stays live on another // (the dual-role central link, or a second bound link after a @@ -3151,6 +3160,7 @@ extension BLEService: CBCentralManagerDelegate { pendingPeripheralWrites.discardAll(for: peripheralID) } noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) @@ -3258,6 +3268,7 @@ extension BLEService { self.pendingPeripheralWrites.discardAll(for: peripheralID) } self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) self.tryConnectFromQueue() @@ -3966,6 +3977,7 @@ extension BLEService: CBPeripheralManagerDelegate { pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } } noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) + noiseReconnectPolicy.endLinkEpoch(.central(centralID)) let removedPeerID = linkStateStore.removeSubscribedCentral(central) // Ensure we're still advertising for other devices to find us @@ -4583,6 +4595,40 @@ extension BLEService { }) } } + + /// A peer-level session can outlive the physical link that established it. + /// Revalidate a fresh direct link with an ordinary XX exchange, retiring + /// cached sending keys atomically before message 1 can leave. + private func refreshNoiseSessionForVerifiedDirectLink( + _ packet: BitchatPacket, + peerID: PeerID + ) { + guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { + return + } + + let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) + let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) + let shouldRevalidate = readLinkState { store in + guard boundPeerID(for: link, in: store) == peerID else { + return false + } + return noiseReconnectPolicy.shouldRevalidate( + on: link, + hasEstablishedSession: hasEstablishedSession, + isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID, + hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty, + now: Date() + ) + } + guard shouldRevalidate else { return } + + SecureLogger.info( + "🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))…", + category: .session + ) + initiateNoiseReconnectHandshake(with: peerID) + } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in @@ -4602,6 +4648,29 @@ extension BLEService { self.broadcastNoiseHandshake(message, to: peerID) } } + service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in + guard let self, let service else { return } + self.messageQueue.async { [weak self, weak service] in + guard let self, + let service, + self.noiseService === service, + let fingerprint = service.getPeerFingerprint(peerID) else { + return + } + SecureLogger.debug( + "🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))…", + category: .session + ) + // Re-enter the same generation-bound transition used after a + // successful handshake. This restores authenticated protocol + // state and drains both PM and typed-payload queues. + self.handleNoisePeerAuthenticated( + peerID: peerID, + fingerprint: fingerprint, + sessionGeneration: generation + ) + } + } } private func handleNoisePeerAuthenticated( @@ -4855,8 +4924,9 @@ extension BLEService { } return } - guard noiseService.hasSession(with: peerID) else { - // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake + guard noiseService.hasEstablishedSession(with: peerID) else { + // No established session yet - queue the payload synchronously + // before initiating a handshake // to prevent race where fast handshake completion drains empty queue collectionsQueue.sync(flags: .barrier) { self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID) @@ -5786,6 +5856,7 @@ extension BLEService { self.pendingPeripheralWrites.discardAll(for: peripheralID) } self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) cancelled += 1 } @@ -5874,6 +5945,37 @@ extension BLEService { ) broadcastPacket(packet) } + + /// Starts a wire-compatible ordinary XX reconnect. The manager prepares + /// the initiator before atomically retiring the cached transport; the + /// one-shot claim prevents a crossed inbound message from making a stale + /// message 1 leave after this peer has already become responder. + private func initiateNoiseReconnectHandshake(with peerID: PeerID) { + let service = noiseService + do { + let initiation = try service.initiateReconnectHandshake(with: peerID) + noteNoiseSessionCleared(for: peerID) + 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 NoiseSessionError.notEstablished { + initiateNoiseHandshake(with: peerID) + } catch { + SecureLogger.error( + "Failed to initiate ordinary reconnect: \(error)", + category: .session + ) + } + } private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) @@ -6429,6 +6531,9 @@ extension BLEService { // consolidate duplicate same-role connections onto that link. if let result, result.isVerified, result.isDirectAnnounce { rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) + #if DEBUG + _test_afterVerifiedDirectRebindEnqueued?() + #endif retireRedundantPeripheralLinks(packet, to: result.peerID) } @@ -6462,11 +6567,9 @@ extension BLEService { deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey) if result.isDirectAnnounce, !hasCurrentNoiseAuthenticatedLink(to: result.peerID) { - if noiseService.hasEstablishedSession(with: result.peerID) { - // A session with no surviving authenticated link is stale; - // force the current link to prove possession again. - clearNoiseSession(for: result.peerID) - } + // A cached session may predate this physical link. + // rebindLinkAfterVerifiedDirectAnnounce performs its atomic + // ordinary reconnect after the binding is published. if !noiseService.hasSession(with: result.peerID) { initiateNoiseHandshake(with: result.peerID) } @@ -6495,7 +6598,14 @@ extension BLEService { linkUUID = centralUUID previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) } - guard let previousPeerID, previousPeerID != peerID else { return } + guard let previousPeerID else { return } + guard previousPeerID != peerID else { + self.refreshNoiseSessionForVerifiedDirectLink( + packet, + peerID: peerID + ) + return + } // The signature does not authenticate directness (TTL is excluded // from signing because relays mutate it), so a "verified direct" @@ -6522,12 +6632,20 @@ extension BLEService { // it across an announce-driven rebind, whose direct TTL is // replayable; the new owner must complete a fresh handshake. self.noiseAuthenticatedLinkOwners.removeValue(forKey: link) + self.noiseReconnectPolicy.endLinkEpoch(link) switch link { case .peripheral(let peripheralUUID): self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) case .central(let centralUUID): self.linkStateStore.bindCentral(centralUUID, to: peerID) } + // Keep the rebind and reconnect decision in one bleQueue critical + // section. No observer may see the new binding while a cached + // peer-level sender is still considered established. + self.refreshNoiseSessionForVerifiedDirectLink( + packet, + peerID: peerID + ) SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) self.refreshLocalTopology() // The announce that triggered this rebind was upserted as @@ -6619,6 +6737,7 @@ extension BLEService { pendingPeripheralWrites.discardAll(for: uuid) } noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) _ = linkStateStore.removePeripheral(uuid) SecureLogger.info( "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", @@ -7001,10 +7120,16 @@ extension BLEService { } private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + let wasEstablished = noiseService.hasEstablishedSession(with: peerID) let result = noisePacketHandler.handleHandshakeWithResult( packet, from: peerID ) + let isEstablished = noiseService.hasEstablishedSession(with: peerID) + if wasEstablished, result.processed, + !isEstablished { + noteNoiseSessionCleared(for: peerID) + } if result.didEstablishAuthenticatedSession { markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) } diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 54972f2e..3b73d494 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -189,6 +189,10 @@ final class NoiseEncryptionService { /// 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)? + /// 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. + var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)? // Add a handler for peer authentication func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { @@ -219,7 +223,11 @@ final class NoiseEncryptionService { } } - init(keychain: KeychainManagerProtocol) { + init( + keychain: KeychainManagerProtocol, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + ) { self.keychain = keychain self.localPrekeys = LocalPrekeyStore(keychain: keychain) @@ -309,7 +317,11 @@ final class NoiseEncryptionService { self.signingPublicKey = signingKey.publicKey // Initialize session manager - self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) + self.sessionManager = NoiseSessionManager( + localStaticKey: staticIdentityKey, + keychain: keychain, + ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout + ) // Set up session callbacks sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in @@ -319,6 +331,9 @@ final class NoiseEncryptionService { sessionGeneration: generation ) } + sessionManager.onSessionRestored = { [weak self] peerID, generation in + self?.onSessionRestoredWithGeneration?(peerID, generation) + } // Start session maintenance timer startRekeyTimer() @@ -682,6 +697,37 @@ final class NoiseEncryptionService { let handshakeData = try sessionManager.initiateHandshake(with: peerID) return handshakeData } + + /// 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 + ) throws -> NoiseHandshakeInitiation { + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + return try sessionManager.initiateReconnectHandshake( + with: peerID, + authorize: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning( + .authenticationFailed(peerID: "Rate limited: \(peerID)") + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) + } + + func claimHandshakeInitiation( + _ initiation: NoiseHandshakeInitiation, + for peerID: PeerID + ) -> Data? { + sessionManager.claimHandshakeInitiation(initiation, for: peerID) + } /// Process an incoming handshake message func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { @@ -819,8 +865,10 @@ final class NoiseEncryptionService { throw NoiseSecurityError.rateLimitExceeded } - // Check if we have an established session - guard hasEstablishedSession(with: peerID) else { + // A quarantined transport is deliberately unavailable for outbound + // state, but remains receive-only until the responder proves identity + // or the bounded rollback restores it. + guard sessionManager.hasReceiveSession(for: peerID) else { throw NoiseEncryptionError.sessionNotEstablished } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 3835df80..32da0664 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -502,14 +502,26 @@ struct BLEServiceCoreTests { ) let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce") #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink)) + let rebindGate = VerifiedDirectRebindGate() + ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause + defer { + rebindGate.release() + ble._test_afterVerifiedDirectRebindEnqueued = nil + } ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false) - let rebound = await TestHelpers.waitUntil( - { ble._test_centralBinding(attackerLink) == victimPeerID }, + let announcePaused = await TestHelpers.waitUntil( + { rebindGate.hasPaused }, timeout: TestConstants.longTimeout ) - #expect(rebound) - #expect(ble.canDeliverSecurely(to: victimPeerID)) + try #require(announcePaused) + + // Rebind and ordinary reconnect preparation are one bleQueue + // critical section. Once the binding is visible, stale sending keys + // must already be unavailable. + #expect(ble._test_centralBinding(attackerLink) == victimPeerID) + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + rebindGate.release() let outbound = OutboundPacketTap() ble._test_onOutboundPacket = { outbound.record($0) } @@ -614,7 +626,117 @@ struct BLEServiceCoreTests { for: victimPeerID ) ) - #expect(ble.canDeliverSecurely(to: victimPeerID)) + // Ordinary reconnect hardening quarantines the cached transport while + // this candidate proves the claimed identity. It must be unavailable + // for sending as well as unable to authenticate this ingress link. + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + } + + @Test + func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws { + let ble = makeService() + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + + let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID) + let message2 = try #require( + try alice.processHandshakeMessage(from: ble.myPeerID, message: message1) + ) + let message3 = try #require( + try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2) + ) + _ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3) + await ble._test_drainNoiseMessagePipeline() + #expect(ble.canDeliverSecurely(to: alicePeerID)) + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID) + let firstPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: forgedMessage1, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID) + + let responseReady = await TestHelpers.waitUntil( + { + outbound.snapshot().contains { + $0.type == MessageType.noiseHandshake.rawValue + } + }, + timeout: TestConstants.longTimeout + ) + try #require(responseReady) + let forgedMessage2 = try #require( + outbound.snapshot().first { + $0.type == MessageType.noiseHandshake.rawValue + }?.payload + ) + #expect(!ble.canDeliverSecurely(to: alicePeerID)) + + // Typed control traffic must queue behind the ordinary responder, + // rather than attempting encryption and disappearing. + let privateMessageID = "quarantine-pm-\(UUID().uuidString)" + ble.sendPrivateMessage( + "queued private message", + to: alicePeerID, + recipientNickname: "Alice", + messageID: privateMessageID + ) + ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + + let forgedMessage3 = try #require( + try mallory.processHandshakeMessage( + from: ble.myPeerID, + message: forgedMessage2 + ) + ) + let thirdPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1, + payload: forgedMessage3, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID) + + // Restore re-enters the generation-bound authentication transition: + // authenticated state and both outbound queues drain exactly once. + let drained = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseEncrypted) >= 3 }, + timeout: TestConstants.longTimeout + ) + try #require(drained) + await ble._test_drainNoiseMessagePipeline() + let plaintexts = try outbound.snapshot() + .filter { $0.type == MessageType.noiseEncrypted.rawValue } + .map { try alice.decrypt($0.payload, from: ble.myPeerID) } + #expect(plaintexts.count == 3) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.authenticatedPeerState.rawValue + }.count == 1 + ) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.privateMessage.rawValue + }.count == 1 + ) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.groupInvite.rawValue + }.count == 1 + ) } /// A legitimate rotation announce necessarily arrives on a link still @@ -942,6 +1064,40 @@ private final class OutboundPacketTap { lock.lock(); defer { lock.unlock() } return packets.filter { $0.type == type.rawValue }.count } + + func snapshot() -> [BitchatPacket] { + lock.lock(); defer { lock.unlock() } + return packets + } +} + +private final class VerifiedDirectRebindGate: @unchecked Sendable { + private let condition = NSCondition() + private var paused = false + private var released = false + + var hasPaused: Bool { + condition.lock() + defer { condition.unlock() } + return paused + } + + func pause() { + condition.lock() + paused = true + condition.broadcast() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } } private final class ReceivePacketHandoffGate: @unchecked Sendable { diff --git a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift new file mode 100644 index 00000000..fd334d64 --- /dev/null +++ b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift @@ -0,0 +1,115 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE Noise reconnect policy") +struct BLENoiseReconnectPolicyTests { + @Test("Revalidation requires a cached session and no authenticated link") + func revalidationPreconditions() { + var policy = BLENoiseReconnectPolicy() + let link = BLEIngressLinkID.peripheral("peripheral-a") + let now = Date(timeIntervalSince1970: 1_000) + + let withoutSession = policy.shouldRevalidate( + on: link, + hasEstablishedSession: false, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: now + ) + #expect(!withoutSession) + let authenticated = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: true, + hasAuthenticatedPeerLink: true, + now: now + ) + #expect(!authenticated) + let eligible = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: now + ) + #expect(eligible) + } + + @Test("Revalidation is once per link epoch or after sixty seconds") + func revalidationIsBoundPerLinkEpoch() { + var policy = BLENoiseReconnectPolicy() + let link = BLEIngressLinkID.central("central-a") + let start = Date(timeIntervalSince1970: 2_000) + + let initial = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start + ) + #expect(initial) + let duringCooldown = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start.addingTimeInterval(59.999) + ) + #expect(!duringCooldown) + let afterCooldown = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start.addingTimeInterval(60) + ) + #expect(afterCooldown) + + policy.endLinkEpoch(link) + let nextEpoch = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start.addingTimeInterval(60.001) + ) + #expect(nextEpoch) + } + + @Test("An authenticated sibling suppresses redundant reconnect") + func authenticatedSiblingSuppressesReconnect() { + var policy = BLENoiseReconnectPolicy() + let link = BLEIngressLinkID.peripheral("unproven-sibling") + let start = Date(timeIntervalSince1970: 3_000) + + let suppressed = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: true, + now: start + ) + #expect(!suppressed) + let eligible = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start + ) + #expect(eligible) + } + + @Test("Reserved replacement bit is not advertised") + func reservedReplacementBitIsNotAdvertised() { + #expect( + !PeerCapabilities.localSupported.contains( + .nonDestructiveNoiseReplacement + ) + ) + #expect(PeerCapabilities.localSupported.contains(.privateMedia)) + } +} diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index b6443a25..3520cb1f 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -171,8 +171,8 @@ struct NoiseEncryptionServiceTests { #expect(!emittedAuthentication) } - @Test("Failed forged replacement preserves the established peer session") - func forgedReplacementPreservesEstablishedSession() async throws { + @Test("Failed forged reconnect restores the established peer session") + func forgedReconnectRestoresEstablishedSession() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let receiver = NoiseEncryptionService(keychain: MockKeychain()) let mallory = NoiseEncryptionService(keychain: MockKeychain()) @@ -195,20 +195,20 @@ struct NoiseEncryptionServiceTests { let forgedMessage2 = try #require( try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1) ) - // The replacement has not authenticated yet; the working Alice - // transport session must remain available throughout the candidate. - #expect(receiver.hasEstablishedSession(with: alicePeerID)) + // Outbound/session-generation APIs fail closed while the old + // transport is retained solely for receive and bounded rollback. + #expect(!receiver.hasEstablishedSession(with: alicePeerID)) let forgedMessage3 = try #require( try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2) ) do { _ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3) - Issue.record("Expected forged replacement to fail peer binding") + Issue.record("Expected forged reconnect to fail peer binding") } catch let error as NoiseSessionError { #expect(error == .peerIdentityMismatch) } catch { - Issue.record("Unexpected replacement error: \(error)") + Issue.record("Unexpected reconnect error: \(error)") } #expect(receiver.hasEstablishedSession(with: alicePeerID)) @@ -221,8 +221,8 @@ struct NoiseEncryptionServiceTests { #expect(!emittedReplacementAuthentication) } - @Test("Valid rehandshake atomically replaces the established session") - func validRehandshakeReplacesEstablishedSession() throws { + @Test("Valid ordinary rehandshake atomically replaces the established session") + func validOrdinaryRehandshakeReplacesEstablishedSession() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let receiver = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) @@ -235,7 +235,7 @@ struct NoiseEncryptionServiceTests { let message2 = try #require( try receiver.processHandshakeMessage(from: alicePeerID, message: message1) ) - #expect(receiver.hasEstablishedSession(with: alicePeerID)) + #expect(!receiver.hasEstablishedSession(with: alicePeerID)) let message3 = try #require( try alice.processHandshakeMessage(from: receiverPeerID, message: message2) ) @@ -353,6 +353,226 @@ struct NoiseEncryptionServiceTests { #expect(decrypted == typedPayload) } + @Test("Atomic reconnect retires old sending keys before message one") + func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() 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) + let oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID) + #expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8)) + + let initiation = try alice.initiateReconnectHandshake(with: bobPeerID) + #expect(alice.hasSession(with: bobPeerID)) + #expect(!alice.hasEstablishedSession(with: bobPeerID)) + do { + _ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID) + Issue.record("Expected encryption to wait for the reconnect") + } catch NoiseEncryptionError.handshakeRequired { + // Expected: BLE queues behind the ordinary handshaking session. + } catch { + Issue.record("Unexpected in-window encryption error: \(error)") + } + + let message1 = try #require( + alice.claimHandshakeInitiation(initiation, for: bobPeerID) + ) + bob.clearSession(for: alicePeerID) + 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 fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID) + #expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8)) + } + + @Test("Inbound reconnect quarantines old sending keys until identity proof") + func inboundReconnectQuarantinesOldTransport() throws { + let restarted = NoiseEncryptionService(keychain: MockKeychain()) + let retained = NoiseEncryptionService(keychain: MockKeychain()) + let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData()) + let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData()) + + try establishSessions(alice: restarted, bob: retained) + let inFlightOldCiphertext = try restarted.encrypt( + Data("old receive-only transport".utf8), + for: retainedPeerID + ) + restarted.clearSession(for: retainedPeerID) + + let message1 = try restarted.initiateHandshake(with: retainedPeerID) + let message2 = try #require( + try retained.processHandshakeMessage( + from: restartedPeerID, + message: message1 + ) + ) + #expect(!retained.hasEstablishedSession(with: restartedPeerID)) + #expect( + try retained.decrypt( + inFlightOldCiphertext, + from: restartedPeerID + ) == Data("old receive-only transport".utf8) + ) + do { + _ = try retained.encrypt( + Data("must queue at responder".utf8), + for: restartedPeerID + ) + Issue.record("Expected quarantined responder encryption to wait") + } catch NoiseEncryptionError.handshakeRequired { + // Expected. + } catch { + Issue.record("Unexpected quarantine encryption error: \(error)") + } + + let message3 = try #require( + try restarted.processHandshakeMessage( + from: retainedPeerID, + message: message2 + ) + ) + _ = try retained.processHandshakeMessage( + from: restartedPeerID, + message: message3 + ) + + let fresh = try retained.encrypt( + Data("identity proved".utf8), + for: restartedPeerID + ) + #expect( + try restarted.decrypt(fresh, from: retainedPeerID) + == Data("identity proved".utf8) + ) + } + + @Test("Forged reconnect restores the quarantined transport") + func forgedReconnectRestoresQuarantinedTransport() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: bob) + + let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) + let forgedMessage2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: forgedMessage1 + ) + ) + #expect(!bob.hasEstablishedSession(with: alicePeerID)) + let forgedMessage3 = try #require( + try mallory.processHandshakeMessage( + from: bobPeerID, + message: forgedMessage2 + ) + ) + + do { + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: forgedMessage3 + ) + Issue.record("Expected forged static identity to be rejected") + } catch NoiseSessionError.peerIdentityMismatch { + // Expected; the manager restores the quarantined transport. + } catch { + Issue.record("Unexpected forged reconnect error: \(error)") + } + + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let oldTransport = try alice.encrypt( + Data("rollback survived".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(oldTransport, from: alicePeerID) + == Data("rollback survived".utf8) + ) + } + + @Test("Lost reconnect completion restores the quarantined transport") + func timedOutReconnectRestoresQuarantinedTransport() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryResponderHandshakeTimeout: 0.02 + ) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: bob) + let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) + _ = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: forgedMessage1 + ) + ) + #expect(!bob.hasEstablishedSession(with: alicePeerID)) + + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let oldTransport = try alice.encrypt( + Data("timeout rollback".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(oldTransport, from: alicePeerID) + == Data("timeout rollback".utf8) + ) + } + + @Test("Failed reconnect authorization preserves the established transport") + func failedReconnectAuthorizationPreservesSession() 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) + // The initiator exchange consumed two authorizations for this peer. + for _ in 2..