diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index b19ff854..ac98eaf6 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -36,6 +36,30 @@ 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 a6279bce..d70c3aa5 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -16,25 +16,102 @@ struct NoiseHandshakeProcessingResult { let didEstablishAuthenticatedSession: Bool } +struct NoiseHandshakeInitiation: Equatable, Sendable { + let payload: Data + let attemptID: UUID +} + +struct NoiseHandshakeRecoveryRequest: Equatable, Sendable { + let peerID: PeerID + fileprivate let recoveryID: UUID +} + +enum NoiseHandshakeRecoveryPreparation { + case ordinary(NoiseHandshakeInitiation) + case transferred +} + +/// Why a quarantined transport became the active session again. +enum NoiseSessionRestoreReason: Equatable, Sendable { + /// The replacement attempt failed terminally (claimed-identity mismatch, + /// or a failure that owns no convergence retry). The counterpart never + /// finished replacement keys, so the restored generation is immediately + /// valid for outbound traffic. + case terminal + /// The responder window expired — or a recoverable failure occurred — + /// and this manager owns one mandatory convergence retry. The counterpart + /// may already hold replacement keys that discarded the restored ones, so + /// outbound queue drains must wait for the retry to conclude. + case pendingConvergence +} + 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 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 + /// 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 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, NoiseSessionRestoreReason) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? + var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)? - init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { + init( + localStaticKey: Curve25519.KeyAgreement.PrivateKey, + keychain: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: TimeInterval = + 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, @@ -47,10 +124,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 @@ -62,30 +154,170 @@ 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) { - 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() + } + 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() { 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 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() - responderCandidates.removeAll() + ordinaryInitiationIDs.removeAll() + ordinaryInitiatorTimeouts.removeAll() + ordinaryInitiatorRetryNotifications.removeAll() + ordinaryResponderTimeouts.removeAll() + ordinaryResponderDeadlines.removeAll() + ordinaryResponderRetryNotifications.removeAll() + ordinaryRespondersCreatedByYield.removeAll() + recentOrdinaryInitiatorCompletions.removeAll() + quarantinedTransports.removeAll() + quarantineRollbackCooldownUntil.removeAll() + suppressedInitiationRecoveryTimeouts.removeAll() + delayedHandshakeRecoveryWorkItems.removeAll() + handshakeRecoveryCallbackIDs.removeAll() + pendingHandshakeRecoveryIDs.removeAll() } } @@ -101,10 +333,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) - existingSession.reset() + removeSessionLocked(for: peerID) } + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) // Create new initiator session let session = sessionFactory(peerID, .initiator) @@ -113,17 +344,162 @@ 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 } } } + + /// 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) { + 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 + } + + // 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 + ) + } + } + + /// 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) + 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 + } + } func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { try handleIncomingHandshakeWithResult( @@ -152,57 +528,170 @@ final class NoiseSessionManager { generation: UUID )? ) = try managerQueue.sync(flags: .barrier) { - let session: NoiseSession - let isReplacementCandidate: Bool + 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 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 + 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) } - isReplacementCandidate = true - } else if let existing = sessions[peerID] { - if existing.isEstablished() { - SecureLogger.info( - "Validating replacement handshake from \(peerID) while preserving the established session", + + 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 ) - 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. + 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) - existing.reset() + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + ordinaryInitiator.reset() + } + } + + let session: NoiseSession + + if let existing = sessions[peerID] { + if existing.isEstablished(), + !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 - isReplacementCandidate = false } else { session = existing - isReplacementCandidate = false } } else { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession sessionGenerations[peerID] = UUID() session = newSession - isReplacementCandidate = false } - - // 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 @@ -218,14 +707,24 @@ 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 + ) { + 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 } @@ -234,27 +733,88 @@ 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 { + 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() - + + let isIdentityMismatch = + (error as? NoiseSessionError) == .peerIdentityMismatch + + 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 + } + + // An identity mismatch is terminal: the counterpart failed to + // prove the claimed static key, so it never finished keys that + // could have replaced the restored ones. Every other restore + // that owns (or joins) a convergence retry must keep transport + // queues parked until that retry concludes — the counterpart + // may already have discarded the restored sending keys. + let restoreReason: NoiseSessionRestoreReason = + !isIdentityMismatch + && (shouldRequestRecovery + || pendingHandshakeRecoveryIDs[peerID] != nil) + ? .pendingConvergence + : .terminal + // Schedule callback outside the synchronized block to prevent deadlock DispatchQueue.global().async { [weak self] in + if let restoredGeneration { + self?.onSessionRestored?(peerID, restoredGeneration, restoreReason) + } self?.onSessionFailed?(peerID, error) } + 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 } } @@ -268,6 +828,271 @@ final class NoiseSessionManager { ) } + private func scheduleOrdinaryInitiatorTimeoutLocked( + _ session: NoiseSession, + for peerID: PeerID, + notifyOnTimeout: Bool + ) { + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + ordinaryInitiatorRetryNotifications[peerID] = notifyOnTimeout + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in + guard let self, + let session, + let current = self.sessions[peerID], + 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.ordinaryInitiatorTimeouts.removeValue(forKey: peerID) + self.ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) + self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + session.reset() + SecureLogger.debug( + "Ordinary initiator handshake with \(peerID) timed out", + category: .session + ) + + if notifyOnTimeout { + self.requestHandshakeRecovery(for: 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 { + // The mandatory convergence retry below owns the outbound + // resume: the timed-out counterpart may hold replacement keys + // that already discarded the restored generation's, so queue + // drains under it would be silently undecryptable. + DispatchQueue.global().async { [weak self] in + self?.onSessionRestored?( + peerID, + restored.generation, + .pendingConvergence + ) + } + } + + // 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 + } + 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 /// also accepted by internal callers when they exactly match the static /// key. Non-wire identifiers remain available to protocol test harnesses; @@ -299,7 +1124,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 +1159,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, @@ -383,11 +1238,11 @@ final class NoiseSessionManager { } } - func initiateRekey(for peerID: PeerID) throws -> Data { - // Remove old session - removeSession(for: peerID) - - // Initiate new handshake - return try initiateHandshake(with: peerID) + func initiateRekey(for peerID: PeerID) throws -> NoiseHandshakeInitiation { + try initiateReconnectHandshake( + with: peerID, + notifyOnTimeout: true, + authorize: {} + ) } } diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index 736632e9..19735ec2 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 @@ -112,8 +112,16 @@ 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 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 f246d5cd..5a806a97 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,13 @@ 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)? + /// May block the convergence-recovery callback on its global-queue thread + /// before it enqueues onto `messageQueue`. Tests use this boundary to + /// force the quarantine-restore handler to win the dispatch race. + var _test_beforeHandshakeRecoveryEnqueued: ((PeerID) -> Void)? #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() @@ -383,6 +391,9 @@ final class BLEService: NSObject { // MARK: - Identity private var noiseService: NoiseEncryptionService + /// Injected so tests can compress the quarantine/rollback window; + /// production always passes the security-constant default. + private let noiseResponderHandshakeTimeout: TimeInterval private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge @@ -499,14 +510,20 @@ final class BLEService: NSObject { identityManager: SecureIdentityStateManagerProtocol, initializeBluetoothManagers: Bool = true, incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), - startSuspendedForPanicRecovery: Bool = false + startSuspendedForPanicRecovery: Bool = false, + noiseResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout ) { self.keychain = keychain self.idBridge = idBridge self.incomingFileStore = incomingFileStore self.shouldInitializeBluetoothManagers = initializeBluetoothManagers self._isPanicSuspended = startSuspendedForPanicRecovery - noiseService = NoiseEncryptionService(keychain: keychain) + self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout + noiseService = NoiseEncryptionService( + keychain: keychain, + ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout + ) self.identityManager = identityManager super.init() @@ -764,6 +781,8 @@ final class BLEService: NSObject { bleQueue.sync { pendingWriteBuffers.removeAll() + noiseAuthenticatedLinkOwners.removeAll() + noiseReconnectPolicy.removeAll() connectionScheduler.reset() } disconnectNotifyDebouncer.removeAll() @@ -777,7 +796,10 @@ final class BLEService: NSObject { noiseService.clearEphemeralStateForPanic() noiseService.clearPersistentIdentity() - let newNoise = NoiseEncryptionService(keychain: keychain) + let newNoise = NoiseEncryptionService( + keychain: keychain, + ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout + ) noiseService = newNoise configureNoiseServiceCallbacks(for: newNoise) refreshPeerIdentity() @@ -1061,6 +1083,7 @@ final class BLEService: NSObject { bleQueue.sync { linkStateStore.clearAll() noiseAuthenticatedLinkOwners.removeAll() + noiseReconnectPolicy.removeAll() connectionScheduler.reset() subscriptionAnnounceLimiter.removeAll() } @@ -2628,6 +2651,7 @@ final class BLEService: NSObject { } for link in departedLinks { noiseAuthenticatedLinkOwners.removeValue(forKey: link) + noiseReconnectPolicy.endLinkEpoch(link) } } _ = collectionsQueue.sync(flags: .barrier) { @@ -2947,14 +2971,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 @@ -2967,7 +2998,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: @@ -3115,6 +3145,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 @@ -3169,6 +3200,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) @@ -3276,6 +3308,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() @@ -3435,6 +3468,20 @@ extension BLEService { try noiseService.processHandshakeMessage(from: peerID, message: message) } + func _test_enqueuePendingPrivateMessage( + content: String, + messageID: String, + for peerID: PeerID + ) { + collectionsQueue.sync(flags: .barrier) { + pendingNoiseSessionQueues.appendPrivateMessage( + content: content, + messageID: messageID, + for: peerID + ) + } + } + func _test_enqueuePendingNoisePayload( _ payload: Data, transferId: String, @@ -3856,8 +3903,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 @@ -3871,7 +3929,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 @@ -3984,6 +4041,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 @@ -4601,6 +4659,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 @@ -4613,19 +4705,111 @@ 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 } + #if DEBUG + self._test_beforeHandshakeRecoveryEnqueued?(request.peerID) + #endif + 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, reason 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 to restore authenticated protocol + // state. Only a terminal restore may also drain the PM and + // typed-payload queues: after a responder timeout the + // counterpart may have completed the replacement handshake + // and discarded the restored keys, so encrypting the queues + // under them would lose every message silently. The mandatory + // convergence retry that accompanies the restore drains them + // under the new session instead (any establishment does). + self.handleNoisePeerAuthenticated( + peerID: peerID, + fingerprint: fingerprint, + sessionGeneration: generation, + deferOutboundUntilConvergence: reason == .pendingConvergence + ) + } + } } private func handleNoisePeerAuthenticated( peerID: PeerID, fingerprint: String, - sessionGeneration generation: UUID + sessionGeneration generation: UUID, + deferOutboundUntilConvergence: Bool = false ) { let normalizedPeerID = peerID.toShort() guard let transition = noiseService.withCurrentSessionGeneration( @@ -4677,6 +4861,21 @@ extension BLEService { nonce: watchdog.nonce ) + if deferOutboundUntilConvergence { + // Timeout-restore: the session is back for receive purposes and + // the generation-bound protocol state above is rebuilt, but the + // counterpart may already hold replacement keys that discarded + // this generation's. Encrypting the pending queues here would + // lose them silently, so leave them parked: the restore's + // mandatory convergence retry — or any later handshake the + // reconnect policy initiates — re-enters this transition with a + // fresh generation and drains them under keys both sides hold. + #if DEBUG + _test_onPrivateMediaSessionReconciled?(normalizedPeerID) + #endif + return + } + // `onPeerAuthenticated` can fire while the initiator is returning XX // message 3. This callback is queued behind the handshake handler, so // message 3 is broadcast first. Both peers also send one idempotent @@ -4874,8 +5073,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) @@ -5805,6 +6005,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 } @@ -5870,12 +6071,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)") } @@ -5893,6 +6109,42 @@ 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, + retryOnTimeout: true + ) + 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 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) @@ -6448,6 +6700,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) } @@ -6481,11 +6736,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) } @@ -6514,7 +6767,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" @@ -6541,12 +6801,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 @@ -6638,6 +6906,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))…)" } ?? "")", @@ -7040,10 +7309,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..9055d77c 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -184,11 +184,20 @@ 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 may only be drained for this exact restored + /// generation when the reason is terminal; a restore that owns a pending + /// convergence retry must keep them parked until the retry concludes. + var onSessionRestoredWithGeneration: + ((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)? // Add a handler for peer authentication func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { @@ -219,7 +228,17 @@ final class NoiseEncryptionService { } } - init(keychain: KeychainManagerProtocol) { + init( + keychain: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown + ) { self.keychain = keychain self.localPrekeys = LocalPrekeyStore(keychain: keychain) @@ -309,7 +328,17 @@ final class NoiseEncryptionService { self.signingPublicKey = signingKey.publicKey // Initialize session manager - self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) + self.sessionManager = NoiseSessionManager( + localStaticKey: staticIdentityKey, + keychain: keychain, + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: + recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: + ordinaryReconnectRollbackCooldown + ) // Set up session callbacks sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in @@ -319,6 +348,12 @@ final class NoiseEncryptionService { sessionGeneration: generation ) } + sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in + self?.onSessionRestoredWithGeneration?(peerID, generation, reason) + } + sessionManager.onHandshakeRecoveryRequired = { [weak self] request in + self?.onHandshakeRecoveryRequired?(request) + } // Start session maintenance timer startRekeyTimer() @@ -682,6 +717,90 @@ final class NoiseEncryptionService { let handshakeData = try sessionManager.initiateHandshake(with: peerID) 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, + retryOnTimeout: Bool = false + ) throws -> NoiseHandshakeInitiation { + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + return try sessionManager.initiateReconnectHandshake( + with: peerID, + notifyOnTimeout: retryOnTimeout, + authorize: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning( + .authenticationFailed(peerID: "Rate limited: \(peerID)") + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) + } + + 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 + ) -> Data? { + sessionManager.claimHandshakeInitiation(initiation, for: peerID) + } /// Process an incoming handshake message func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { @@ -819,8 +938,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 } @@ -943,9 +1064,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 a6a3a85b..517fdf2a 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) } @@ -537,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" @@ -614,7 +632,330 @@ 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()) + + // Establish BLE as responder so the following inbound reconnect is + // not intentionally coalesced by the initiator-completion grace path. + let message1 = try alice.initiateHandshake(with: ble.myPeerID) + let message2 = try #require( + try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + let message3 = try #require( + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: message2 + ) + ) + _ = try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + 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 + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize + } + }, + timeout: TestConstants.longTimeout + ) + try #require(responseReady) + let forgedMessage2 = try #require( + outbound.snapshot().first { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize + }?.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 + ) + } + + /// The message-loss interleaving behind a legitimate peer restart: the + /// remote completes a replacement handshake (discarding the old keys) + /// but its completion never arrives, so the local responder timeout + /// restores the quarantined OLD generation and requests one convergence + /// retry — both dispatched unordered. When the restore handler wins the + /// race, it must NOT drain the pending private-message/typed-payload + /// queues under the restored keys the remote no longer holds; the drain + /// has to wait for the convergence handshake and use its new session. + @Test + func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws { + let ble = makeService(noiseResponderHandshakeTimeout: 0.3) + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + + // Establish BLE as responder so the inbound reconnect below is not + // coalesced by the initiator-completion grace path. + let message1 = try alice.initiateHandshake(with: ble.myPeerID) + let message2 = try #require( + try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + let message3 = try #require( + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: message2 + ) + ) + _ = try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message3 + ) + await ble._test_drainNoiseMessagePipeline() + #expect(ble.canDeliverSecurely(to: alicePeerID)) + + // The convergence retry only prepares for reachable peers. + ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice") + + let reconciled = SessionReconcileCounter() + ble._test_onPrivateMediaSessionReconciled = reconciled.record + // Park the convergence-recovery callback on its global-queue thread + // before it can enqueue onto messageQueue: the restore handler + // deterministically wins the dispatch race this test exercises. + let recoveryGate = HandshakeRecoveryEnqueueGate() + defer { recoveryGate.release() } + ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() } + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + // Park the traffic the race would lose directly in the pending + // queues — the same place live sends land during quarantine — so no + // assertion below depends on outrunning the responder deadline under + // parallel test load. Nothing drains these queues while the current + // session stays untouched. + ble._test_enqueuePendingPrivateMessage( + content: "deferred private message", + messageID: "deferred-pm-\(UUID().uuidString)", + for: alicePeerID + ) + ble._test_enqueuePendingNoisePayload( + NoisePayload( + type: .groupInvite, + data: Data("queued-during-quarantine".utf8) + ).encode(), + transferId: "deferred-invite-\(UUID().uuidString)", + for: alicePeerID + ) + + // An unauthenticated message 1 quarantines the established transport. + // Its message 3 never arrives, modeling the restarted peer whose + // completion was lost after it already discarded the old keys. + let reconnectMessage1 = try mallory.initiateHandshake(with: ble.myPeerID) + let reconnectPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: reconnectMessage1, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(reconnectPacket, fromPeerID: alicePeerID) + // The responder's message 2 is a monotonic quarantine signal; the + // secure-delivery dip itself only lasts until the responder deadline, + // which parallel test load can outrun. (The recovery gate keeps the + // convergence retry's message 1 out of the tap until released.) + let responderReady = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseHandshake) >= 1 }, + timeout: TestConstants.longTimeout + ) + try #require(responderReady) + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + + // The responder timeout restores the quarantined generation; the + // gate guarantees its handler runs before the convergence retry. + let restoreRan = await TestHelpers.waitUntil( + { reconciled.count(for: alicePeerID) == 1 }, + timeout: TestConstants.longTimeout + ) + try #require(restoreRan) + #expect(ble.canDeliverSecurely(to: alicePeerID)) + await ble._test_drainNoiseMessagePipeline() + // The parked queues must not have been encrypted under the restored + // old generation the remote may no longer be able to read. + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + + // Release the mandatory convergence retry: it retires the restored + // session and starts a fresh XX exchange with the live peer. + recoveryGate.release() + let retryStarted = await TestHelpers.waitUntil( + { + outbound.snapshot().contains { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + == NoiseSecurityConstants.xxInitialMessageSize + } + }, + timeout: TestConstants.longTimeout + ) + try #require(retryStarted) + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + let retryMessage1 = try #require( + outbound.snapshot().last { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + == NoiseSecurityConstants.xxInitialMessageSize + }?.payload + ) + + // Alice answers the retry as the restarted peer she models: her old + // session is gone, so the retry is a fresh responder exchange (and + // never the initiator-completion grace deferral, whose lower-peerID + // arm would otherwise coalesce the retry for random key orderings). + alice.clearSession(for: ble.myPeerID) + let retryMessage2 = try #require( + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: retryMessage1 + ) + ) + let retryPacket = 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: retryMessage2, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID) + let drained = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseEncrypted) >= 3 }, + timeout: TestConstants.longTimeout + ) + try #require(drained) + await ble._test_drainNoiseMessagePipeline() + + // Alice completes with message 3, then must be able to decrypt every + // drained payload — proving nothing left under the old generation. + let retryMessage3 = try #require( + outbound.snapshot().last { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize + }?.payload + ) + _ = try alice.processHandshakeMessage( + from: ble.myPeerID, + message: retryMessage3 + ) + 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 @@ -943,6 +1284,79 @@ 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 + } +} + +/// Blocks the convergence-recovery callback on its global-queue thread so a +/// test can prove the quarantine-restore handler wins the messageQueue race. +private final class HandshakeRecoveryEnqueueGate: @unchecked Sendable { + private let condition = NSCondition() + private var released = false + + func pause() { + condition.lock() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +/// Thread-safe counter for `_test_onPrivateMediaSessionReconciled` firings. +private final class SessionReconcileCounter: @unchecked Sendable { + private let lock = NSLock() + private var reconciles: [PeerID] = [] + + func record(_ peerID: PeerID) { + lock.lock() + reconciles.append(peerID) + lock.unlock() + } + + func count(for peerID: PeerID) -> Int { + lock.lock(); defer { lock.unlock() } + return reconciles.filter { $0 == peerID }.count + } +} + +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 { @@ -1007,7 +1421,10 @@ private final class PanicIngressObserver: @unchecked Sendable { } } -private func makeService() -> BLEService { +private func makeService( + noiseResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout +) -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) @@ -1015,7 +1432,8 @@ private func makeService() -> BLEService { keychain: keychain, idBridge: idBridge, identityManager: identityManager, - initializeBluetoothManagers: false + initializeBluetoothManagers: false, + noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout ) } diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 11ba1602..e07f0fae 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -12,6 +12,7 @@ import Testing @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat +@Suite("Integration Tests", .serialized) struct IntegrationTests { private var helper = TestNetworkHelper() @@ -272,8 +273,18 @@ struct IntegrationTests { // Re-establish Noise handshake explicitly via managers do { let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) - let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! - let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! + let m2 = try #require( + try helper.noiseManagers["Alice"]!.handleIncomingHandshake( + from: helper.nodes["Bob"]!.peerID, + message: m1 + ) + ) + let m3 = try #require( + try helper.noiseManagers["Bob"]!.handleIncomingHandshake( + from: helper.nodes["Alice"]!.peerID, + message: m2 + ) + ) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) } catch { Issue.record("Failed to re-establish Noise session after restart: \(error)") diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index d9e0b3a9..0131739c 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -8,6 +8,7 @@ import Foundation import CryptoKit +import Testing @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat @@ -27,9 +28,14 @@ final class TestNetworkHelper { node.mockNickname = name nodes[name] = node - // Create/replace Noise manager for this node + // This synchronous helper directly drives all three XX messages and + // has no transport callback loop for delayed collision recovery. let key = Curve25519.KeyAgreement.PrivateKey() - noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) + noiseManagers[name] = NoiseSessionManager( + localStaticKey: key, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) return node } @@ -108,8 +114,18 @@ final class TestNetworkHelper { let peer2ID = nodes[node2]?.peerID else { return } let msg1 = try manager1.initiateHandshake(with: peer2ID) - let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! - let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! + let msg2 = try #require( + try manager2.handleIncomingHandshake( + from: peer1ID, + message: msg1 + ) + ) + let msg3 = try #require( + try manager1.handleIncomingHandshake( + from: peer2ID, + message: msg2 + ) + ) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } 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/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index f7e8e6be..ac8ef93d 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -357,8 +357,18 @@ struct NoiseProtocolTests { @Test func peerRestartDetection() throws { // Establish initial sessions - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) - let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + // This test explicitly drives the three synchronous XX messages and + // does not exercise the transport's delayed collision recovery. + let aliceManager = NoiseSessionManager( + localStaticKey: aliceKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) + let bobManager = NoiseSessionManager( + localStaticKey: bobKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -377,15 +387,24 @@ struct NoiseProtocolTests { let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) // Alice should accept the new handshake (clearing old session) - let newHandshake2 = try aliceManager.handleIncomingHandshake( - from: alicePeerID, message: newHandshake1) - #expect(newHandshake2 != nil) + let newHandshake2 = try #require( + try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: newHandshake1 + ) + ) // Complete the new handshake - let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( - from: bobPeerID, message: newHandshake2!) - #expect(newHandshake3 != nil) - _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) + let newHandshake3 = try #require( + try bobManagerRestarted.handleIncomingHandshake( + from: bobPeerID, + message: newHandshake2 + ) + ) + _ = try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: newHandshake3 + ) // Should be able to exchange messages with new sessions let testMessage = Data("After restart".utf8) @@ -543,8 +562,18 @@ struct NoiseProtocolTests { @Test func nonceDesynchronizationCausesRehandshake() throws { // Test that nonce desynchronization leads to proper re-handshake - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) - let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + // This test explicitly drives the three synchronous XX messages and + // does not exercise the transport's delayed collision recovery. + let aliceManager = NoiseSessionManager( + localStaticKey: aliceKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) + let bobManager = NoiseSessionManager( + localStaticKey: bobKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -572,15 +601,25 @@ struct NoiseProtocolTests { let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID) // Alice should accept despite having a "valid" (but desynced) session - let rehandshake2 = try aliceManager.handleIncomingHandshake( - from: alicePeerID, message: rehandshake1) - #expect(rehandshake2 != nil, "Alice should accept handshake to fix desync") + let rehandshake2 = try #require( + try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: rehandshake1 + ), + "Alice should accept handshake to fix desync" + ) // Complete handshake - let rehandshake3 = try bobManager.handleIncomingHandshake( - from: bobPeerID, message: rehandshake2!) - #expect(rehandshake3 != nil) - _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) + let rehandshake3 = try #require( + try bobManager.handleIncomingHandshake( + from: bobPeerID, + message: rehandshake2 + ) + ) + _ = try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: rehandshake3 + ) // Verify communication works again let testResynced = Data("Resynced".utf8) 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/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..f150c39f 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") @@ -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) ) @@ -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,954 @@ 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 timeoutInterval: TimeInterval = 1 + let service = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: timeoutInterval + ) + let peerID = PeerID(str: "1021324354657687") + let recorder = HandshakeStartRecorder() + service.onHandshakeRecoveryRequired = { [weak service] request in + let firedAt = DispatchTime.now().uptimeNanoseconds + service?.cancelHandshakeRecovery(request) + recorder.recordTimeout(at: firedAt) + } + + let attempt = try #require( + try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) + ) + try? await Task.sleep(nanoseconds: 500_000_000) + let claimed = service.claimHandshakeInitiation(attempt, for: peerID) + let claimedAt = DispatchTime.now().uptimeNanoseconds + #expect(claimed == attempt.payload) + let expired = await TestHelpers.waitUntil( + { recorder.timeoutCount == 1 }, + timeout: 5 + ) + #expect(expired) + let firedAt = try #require(recorder.firstTimeoutUptimeNanoseconds) + try #require(firedAt >= claimedAt) + let elapsed = TimeInterval(firedAt - claimedAt) / 1_000_000_000 + // A non-rearmed deadline would fire roughly 0.5 seconds after the + // claim. Measure on the timeout queue instead of relying on a task to + // resume inside a narrow pre-deadline window under parallel CI load. + #expect(elapsed >= timeoutInterval * 0.75) + #expect(!service.hasSession(with: peerID)) + #expect(recorder.timeoutCount == 1) + } + + @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 + ) + ) + // Exercise replacement before yielding: the test runner may resume a + // short sleep after the fixed responder deadline under parallel load. + _ = 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 timeoutInterval: TimeInterval = 1 + let endpoints = orderedServices( + ordinaryHandshakeTimeout: timeoutInterval, + ordinaryResponderHandshakeTimeout: timeoutInterval + ) + let modern = endpoints.higher + let legacy = endpoints.lower + let recovery = HandshakeStartRecorder() + modern.onHandshakeRecoveryRequired = { request in + // Preparing here would arm the retry before the test task can + // forward message 1. Record the token so preparation and the + // simulated on-wire exchange remain synchronous. + recovery.recordTimeout() + recovery.record(request: request) + } + + 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 recoveryRequested = await TestHelpers.waitUntil( + { recovery.requests.count == 1 }, + timeout: 5 + ) + #expect(recoveryRequested) + let recoveryRequest = try #require(recovery.requests.first) + let retryMessage1 = try #require( + try claimPreparedRecoveryPayload( + modern, + request: recoveryRequest + ) + ) + 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: UInt64(timeoutInterval * 1_200_000_000) + ) + #expect(recovery.timeoutCount == 1) + 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()) + 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("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()) + 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.. ( + 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)] = [] @@ -476,3 +1477,101 @@ 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 storedRequests: [NoiseHandshakeRecoveryRequest] = [] + private var storedErrorCount = 0 + private var storedTimeoutCount = 0 + private var storedTimeoutUptimes: [UInt64] = [] + + var messages: [Data] { + lock.lock() + defer { lock.unlock() } + return storedMessages + } + + var requests: [NoiseHandshakeRecoveryRequest] { + lock.lock() + defer { lock.unlock() } + return storedRequests + } + + var errorCount: Int { + lock.lock() + defer { lock.unlock() } + return storedErrorCount + } + + var timeoutCount: Int { + lock.lock() + defer { lock.unlock() } + return storedTimeoutCount + } + + var firstTimeoutUptimeNanoseconds: UInt64? { + lock.lock() + defer { lock.unlock() } + return storedTimeoutUptimes.first + } + + func record(message: Data?) { + guard let message else { return } + lock.lock() + storedMessages.append(message) + lock.unlock() + } + + func record(request: NoiseHandshakeRecoveryRequest) { + lock.lock() + storedRequests.append(request) + lock.unlock() + } + + func record(error _: Error) { + lock.lock() + storedErrorCount += 1 + lock.unlock() + } + + func recordTimeout( + at uptimeNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds + ) { + lock.lock() + storedTimeoutCount += 1 + storedTimeoutUptimes.append(uptimeNanoseconds) + lock.unlock() + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index 8438564b..b0a168da 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -28,6 +28,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// before outer BLE fragmentation. Peers that omit this bit require the /// signed directed raw-file migration fallback. public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) + /// Reserved for test builds that briefly advertised non-destructive Noise + /// replacement. Current clients intentionally do not advertise or act on + /// this bit; keep it decodable so the wire assignment is never reused. + public static let nonDestructiveNoiseReplacement = + PeerCapabilities(rawValue: 1 << 10) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index 49012128..9222e2e6 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -20,6 +20,10 @@ struct PeerCapabilitiesTests { let high = PeerCapabilities(rawValue: 1 << 9) #expect(high.encoded() == Data([0x00, 0x02])) + #expect( + PeerCapabilities.nonDestructiveNoiseReplacement.encoded() + == Data([0x00, 0x04]) + ) let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia] #expect(PeerCapabilities(encoded: all.encoded()) == all)