diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index dfe9377b..9c084c12 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -141,6 +141,16 @@ final class NoiseSessionManager { } } + /// Whether this peer has an inbound ordinary XX responder that still + /// needs message 3 before its receive keys can become authoritative. + func isAwaitingResponderHandshakeCompletion(for peerID: PeerID) -> Bool { + managerQueue.sync { + guard let session = sessions[peerID] else { return false } + return session.role == .responder + && session.getState() == .handshaking + } + } + /// 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 @@ -1121,12 +1131,22 @@ final class NoiseSessionManager { /// the exact session object that authenticated these bytes. func decryptWithSessionGeneration( _ ciphertext: Data, - from peerID: PeerID + from peerID: PeerID, + establishedGenerationIsReady: (UUID) -> Bool = { _ in true }, + authorizeDecrypt: () throws -> Void = {} ) throws -> (plaintext: Data, sessionGeneration: UUID) { try managerQueue.sync { if let session = sessions[peerID], session.isEstablished(), let generation = sessionGenerations[peerID] { + // Keep the generation lease across the transport-readiness + // check and decrypt. Promotion/restoration needs this queue's + // barrier, so no new receive nonce can be consumed before BLE + // installs state for the exact generation. + guard establishedGenerationIsReady(generation) else { + throw NoiseEncryptionError.transportGenerationNotReady + } + try authorizeDecrypt() return (try session.decrypt(ciphertext), generation) } @@ -1138,6 +1158,7 @@ final class NoiseSessionManager { responder.role == .responder, responder.getState() == .handshaking, let quarantined = quarantinedTransports[peerID] { + try authorizeDecrypt() return ( try quarantined.session.decrypt(ciphertext), quarantined.generation diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index 19735ec2..e647f96c 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -1,5 +1,6 @@ import BitFoundation import BitLogger +import CryptoKit import Foundation struct BLENoiseHandshakeHandlingResult { @@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment { -> NoiseHandshakeProcessingResult /// Whether any Noise session (established or pending) exists for the peer (crypto). let hasNoiseSession: (PeerID) -> Bool + /// Whether an inbound ordinary XX responder is waiting for message 3. + let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool /// Initiates a fresh Noise handshake with the peer (crypto + send). let initiateHandshake: (PeerID) -> Void /// Broadcasts a packet on the mesh (caller is already on the message queue). @@ -63,7 +66,28 @@ struct BLENoisePacketHandlerEnvironment { /// processing (with response), encrypted payload decryption and dispatch, /// and session recovery on decrypt failure. final class BLENoisePacketHandler { + private struct DeferredCiphertext { + let packet: BitchatPacket + let receivedAt: Date + } + + /// Early post-handshake packets are normally tiny control messages or + /// queued DMs. Keep the recovery surface deliberately small so an + /// unauthenticated half-handshake cannot create an unbounded memory queue. + private static let maxDeferredPacketsPerPeer = 4 + private static let maxDeferredPacketsGlobal = 32 + /// One legacy sender can immediately follow message 3 with the largest + /// valid private-file ciphertext and has no application-level retry. Keep + /// room for that packet plus a small control-message budget. + private static let maxDeferredBytes = + NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024 + private static let deferredLifetime = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + private let environment: BLENoisePacketHandlerEnvironment + private let deferredLock = NSLock() + private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:] + private var deferredCiphertextBytes = 0 init(environment: BLENoisePacketHandlerEnvironment) { self.environment = environment @@ -105,8 +129,8 @@ final class BLENoisePacketHandler { env.broadcastPacket(responsePacket) } - // Session establishment will trigger onPeerAuthenticated callback - // which will send any pending messages at the right time + // The serialized authentication callback installs transport + // state before it drains any bounded early ciphertext. return BLENoiseHandshakeHandlingResult( processed: true, didEstablishAuthenticatedSession: @@ -151,6 +175,20 @@ final class BLENoisePacketHandler { } func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { + handleEncrypted(packet, from: peerID, isDeferredRetry: false) + } + + /// Called by the transport's serialized authentication callback after it + /// has installed state for the promoted or restored session generation. + func handleSessionAuthenticated(_ peerID: PeerID) { + drainDeferredCiphertextsIfReady(for: peerID) + } + + private func handleEncrypted( + _ packet: BitchatPacket, + from peerID: PeerID, + isDeferredRetry: Bool + ) { let env = environment guard let recipientID = PeerID(hexData: packet.recipientID) else { SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) @@ -192,19 +230,205 @@ final class BLENoisePacketHandler { let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) + } catch NoiseEncryptionError.transportGenerationNotReady { + if isDeferredRetry { + SecureLogger.warning( + "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again", + category: .session + ) + return + } + // The manager promoted or restored keys before BLE's serialized + // callback installed generation-bound transport state. The + // manager rejected this before decrypting, so replay is safe. + deferCiphertext(packet, from: peerID) } catch NoiseEncryptionError.sessionNotEstablished { + if isDeferredRetry { + SecureLogger.warning( + "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable", + category: .session + ) + return + } // We received an encrypted message before establishing a session with this peer. - // Trigger a handshake so future messages can be decrypted. + // An initiator may already have sent message 3 followed by this + // ciphertext, with BLE delivering the ciphertext first. + if env.isAwaitingResponderHandshakeCompletion(peerID) { + deferCiphertext(packet, from: peerID) + return + } + // Otherwise trigger a handshake so future messages can decrypt. SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake") if !env.hasNoiseSession(peerID) { env.initiateHandshake(peerID) } } catch { + if isDeferredRetry { + // An early packet cannot tear down the authenticated session + // merely because its single bounded retry still fails. + SecureLogger.warning( + "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)", + category: .session + ) + return + } + // A responder may retain an older transport as receive-only + // rollback state while ordinary XX waits for message 3. New-key + // ciphertext can fail against those retained receive keys first. + if env.isAwaitingResponderHandshakeCompletion(peerID) { + if isDeferrableEarlyHandshakeFailure(error) { + deferCiphertext(packet, from: peerID) + } else { + SecureLogger.warning( + "Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)", + category: .session + ) + } + return + } + if isDropOnlyCiphertextFailure(error) { + // The packet is attacker-controlled and did not prove a + // transport-state failure. Never let malformed, replayed, + // forged, oversized, or rate-limited bytes evict working keys. + SecureLogger.warning( + "Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)", + category: .security + ) + return + } // Decryption failed - clear the corrupted session and re-initiate handshake - // This handles cases where session state got out of sync (nonce mismatch, etc.) + // Only local/session lifecycle failures reach this path. SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake") env.clearSession(peerID) env.initiateHandshake(peerID) } } + + private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool { + if let noiseError = error as? NoiseError { + switch noiseError { + case .authenticationFailure, .replayDetected: + return true + default: + return false + } + } + if let cryptoError = error as? CryptoKitError, + case .authenticationFailure = cryptoError { + return true + } + return false + } + + private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool { + if let securityError = error as? NoiseSecurityError { + switch securityError { + case .messageTooLarge, .rateLimitExceeded, .invalidPeerID: + return true + case .sessionExpired, .sessionExhausted: + return false + } + } + if let noiseError = error as? NoiseError { + switch noiseError { + case .invalidCiphertext, .authenticationFailure, .replayDetected: + return true + case .uninitializedCipher, .handshakeComplete, + .handshakeNotComplete, .missingLocalStaticKey, + .missingKeys, .invalidMessage, .invalidPublicKey, + .nonceExceeded: + return false + } + } + return error is CryptoKitError + } + + private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) { + guard NoiseSecurityValidator.validatePrivateFileCiphertextSize( + packet.payload + ) else { + SecureLogger.warning( + "Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…", + category: .security + ) + return + } + + let now = environment.now() + deferredLock.lock() + defer { deferredLock.unlock() } + purgeExpiredCiphertextsLocked(now: now) + + let peerCount = deferredCiphertexts[peerID]?.count ?? 0 + let globalCount = deferredCiphertexts.values.reduce(0) { + $0 + $1.count + } + guard peerCount < Self.maxDeferredPacketsPerPeer, + globalCount < Self.maxDeferredPacketsGlobal, + deferredCiphertextBytes + packet.payload.count + <= Self.maxDeferredBytes else { + SecureLogger.warning( + "Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full", + category: .security + ) + return + } + + deferredCiphertexts[peerID, default: []].append( + DeferredCiphertext(packet: packet, receivedAt: now) + ) + deferredCiphertextBytes += packet.payload.count + SecureLogger.debug( + "Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion", + category: .session + ) + } + + private func drainDeferredCiphertextsIfReady(for peerID: PeerID) { + let env = environment + guard !env.isAwaitingResponderHandshakeCompletion(peerID), + env.hasNoiseSession(peerID) else { + return + } + + let now = env.now() + deferredLock.lock() + purgeExpiredCiphertextsLocked(now: now) + let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? [] + deferredCiphertextBytes -= deferred.reduce(0) { + $0 + $1.packet.payload.count + } + deferredLock.unlock() + + guard !deferred.isEmpty else { return } + SecureLogger.debug( + "Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion", + category: .session + ) + for item in deferred { + handleEncrypted(item.packet, from: peerID, isDeferredRetry: true) + } + } + + private func purgeExpiredCiphertextsLocked(now: Date) { + for peerID in Array(deferredCiphertexts.keys) { + guard let items = deferredCiphertexts[peerID] else { continue } + let retained = items.filter { + now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime + } + guard retained.count != items.count else { continue } + + deferredCiphertextBytes -= items.reduce(0) { + $0 + $1.packet.payload.count + } + deferredCiphertextBytes += retained.reduce(0) { + $0 + $1.packet.payload.count + } + if retained.isEmpty { + deferredCiphertexts.removeValue(forKey: peerID) + } else { + deferredCiphertexts[peerID] = retained + } + } + } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index c982e640..c8622934 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3556,6 +3556,28 @@ extension BLEService { } } + /// Replays the current generation's ready callback. Restore tests use + /// this to prove same-generation reconciliation is idempotent. + func _test_reconcileCurrentNoiseSession(for peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + messageQueue.async(flags: .barrier) { [weak self] in + guard let self, + let generation = self.noiseService.sessionGeneration( + for: normalizedPeerID + ), + let fingerprint = self.noiseService.getPeerFingerprint( + normalizedPeerID + ) else { + return + } + self.handleNoisePeerAuthenticated( + peerID: normalizedPeerID, + fingerprint: fingerprint, + sessionGeneration: generation + ) + } + } + /// Builds an authenticated-session packet from an exact typed plaintext. /// Compatibility tests use this to model Android's deployed 0x20 file /// payload and the short-lived 0x09 prerelease payload without exposing a @@ -4649,7 +4671,10 @@ extension BLEService { private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…") - self?.messageQueue.async { [weak self] in + // Authentication can be reported while an initiator is still + // returning XX message 3. Serialize generation-bound state and + // every post-handshake drain behind the handshake packet handler. + self?.messageQueue.async(flags: .barrier) { [weak self] in self?.handleNoisePeerAuthenticated( peerID: peerID, fingerprint: fingerprint, @@ -4724,7 +4749,9 @@ extension BLEService { } service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in guard let self, let service else { return } - self.messageQueue.async { [weak self, weak service] in + // The manager makes restored keys visible atomically. Reconcile + // transport state and queued sends as the next serialized phase. + self.messageQueue.async(flags: .barrier) { [weak self, weak service] in guard let self, let service, self.noiseService === service, @@ -4792,7 +4819,16 @@ extension BLEService { } ) else { return } - guard let watchdog = transition.watchdog else { return } + guard let watchdog = transition.watchdog else { + // A quarantined transport restored the same cryptographic + // generation. Its capability proof and announce state never + // became stale; only work queued while outbound keys were paused + // needs one idempotent ready transition. + noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) + sendPendingMessagesAfterHandshake(for: normalizedPeerID) + sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) + return + } completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) schedulePrivateMediaProofTimeout( @@ -4801,6 +4837,10 @@ extension BLEService { sessionGeneration: generation, nonce: watchdog.nonce ) + // Cross-link delivery can put ciphertext sent immediately after + // message 3 ahead of message 3 itself. Retry the bounded queue only + // after this generation's transport state has been fully installed. + noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) // `onPeerAuthenticated` can fire while the initiator is returning XX // message 3. This callback is queued behind the handshake handler, so @@ -6415,7 +6455,12 @@ extension BLEService { // MARK: Packet Reception private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) { - // Call directly if already on messageQueue, otherwise dispatch + let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue + || packet.type == MessageType.noiseEncrypted.rawValue + + // Capture the panic lifecycle at the first off-messageQueue handoff. + // Noise packets still enter through a barrier so handshake promotion, + // quarantine, and encrypted delivery share one ordered session. if DispatchQueue.getSpecific(key: messageQueueKey) == nil { guard let lifecycleGeneration = capturePanicLifecycleGeneration() else { @@ -6424,7 +6469,8 @@ extension BLEService { #if DEBUG _test_beforeReceivePacketHandoff?() #endif - messageQueue.async { [weak self] in + let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : [] + messageQueue.async(flags: flags) { [weak self] in guard let self, self.isCurrentPanicLifecycleGeneration( lifecycleGeneration @@ -6434,11 +6480,34 @@ extension BLEService { #if DEBUG self._test_onReceivePacketHandoff?() #endif - self.handleReceivedPacket(packet, from: peerID) + self.handleReceivedPacketOnQueue(packet, from: peerID) } return } + if isNoisePacket { + guard let lifecycleGeneration = + capturePanicLifecycleGeneration() else { + return + } + messageQueue.async(flags: .barrier) { [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration( + lifecycleGeneration + ) else { + return + } + self.handleReceivedPacketOnQueue(packet, from: peerID) + } + } else { + handleReceivedPacketOnQueue(packet, from: peerID) + } + } + + private func handleReceivedPacketOnQueue( + _ packet: BitchatPacket, + from peerID: PeerID + ) { let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID) let senderID = context.senderID let messageID = context.messageID @@ -7214,16 +7283,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) - } + // An inbound message 1 quarantines the old transport receive-only. + // Keep its generation-bound BLE state intact: the manager's new + // handshaking generation already gates every outbound policy, while + // a rollback can become ready again without repeating capability + // proof or announce side effects. Only the exact handshake candidate's + // authenticated completion may promote the physical ingress link. if result.didEstablishAuthenticatedSession { markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) } @@ -7261,6 +7330,11 @@ extension BLEService { hasNoiseSession: { [weak self] peerID in self?.noiseService.hasSession(with: peerID) ?? false }, + isAwaitingResponderHandshakeCompletion: { [weak self] peerID in + self?.noiseService.isAwaitingResponderHandshakeCompletion( + with: peerID + ) ?? false + }, initiateHandshake: { [weak self] peerID in self?.initiateNoiseHandshake(with: peerID) }, @@ -7274,7 +7348,14 @@ extension BLEService { guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } let result = try self.noiseService.decryptWithSessionGeneration( payload, - from: peerID + from: peerID, + establishedGenerationIsReady: { generation in + self.collectionsQueue.sync { + self.privateMediaSessionGenerations[ + peerID.toShort() + ] == generation + } + } ) return BLENoiseDecryptionResult( plaintext: result.plaintext, diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index c1a1e878..8d72ad0e 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -853,6 +853,13 @@ final class NoiseEncryptionService { func hasSession(with peerID: PeerID) -> Bool { return sessionManager.getSession(for: peerID) != nil } + + /// True while an inbound ordinary XX responder is waiting for message 3. + /// A small amount of immediately-following ciphertext may arrive first + /// over BLE and must be retried only after responder promotion. + func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool { + sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID) + } // MARK: - Encryption/Decryption @@ -920,20 +927,15 @@ final class NoiseEncryptionService { func decryptWithSessionGeneration( _ data: Data, - from peerID: PeerID + from peerID: PeerID, + establishedGenerationIsReady: (UUID) -> Bool = { _ in true } ) throws -> (plaintext: Data, sessionGeneration: UUID) { // Standard transport ciphertext has 20 bytes of nonce/tag overhead. - // A larger candidate is admitted only up to the framed-file ceiling; + // A larger ciphertext is admitted only up to the framed-file ceiling; // after authenticated decryption it must prove it is `.privateFile`. let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) - guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else { - throw NoiseSecurityError.messageTooLarge - } - - // Check rate limit - guard rateLimiter.allowMessage(from: peerID) else { - throw NoiseSecurityError.rateLimitExceeded - } + let isAdmittedCiphertext = isStandardCiphertext + || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) // A quarantined transport is deliberately unavailable for outbound // state, but remains receive-only until the responder proves identity @@ -942,7 +944,20 @@ final class NoiseEncryptionService { throw NoiseEncryptionError.sessionNotEstablished } - let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID) + let result = try sessionManager.decryptWithSessionGeneration( + data, + from: peerID, + establishedGenerationIsReady: + establishedGenerationIsReady, + authorizeDecrypt: { [rateLimiter] in + guard isAdmittedCiphertext else { + throw NoiseSecurityError.messageTooLarge + } + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + } + ) if !isStandardCiphertext { guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first), NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else { @@ -1159,6 +1174,9 @@ struct NoiseMessage: Codable { enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished + /// Manager keys are established or restored, but BLE has not installed + /// generation-bound transport state. No receive nonce was consumed. + case transportGenerationNotReady /// Envelope references a prekey ID we don't hold (never ours, already /// deleted after its grace window, or wiped in a panic). case unknownPrekey diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 64306544..8e810f68 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -639,7 +639,7 @@ struct BLEServiceCoreTests { } @Test - func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws { + func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws { let ble = makeService() let alice = NoiseEncryptionService(keychain: MockKeychain()) let mallory = NoiseEncryptionService(keychain: MockKeychain()) @@ -722,21 +722,48 @@ struct BLEServiceCoreTests { message: forgedMessage2 ) ) + let forgedEarlyPayload = try #require( + BLENoisePayloadFactory.privateMessage( + content: "forged early message", + messageID: "forged-early" + ) + ) + try #require( + mallory.hasEstablishedSession(with: ble.myPeerID), + "forged initiator did not establish after producing message three" + ) + let forgedEarlyCiphertext = try mallory.encrypt( + forgedEarlyPayload, + for: ble.myPeerID + ) + let earlyPacket = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1, + payload: forgedEarlyCiphertext, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + 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, + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2, 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. + // Rollback restores the same generation. It retries the bounded early + // ciphertext and drains both outbound queues, but must not repeat a + // new-generation capability proof or forced announce. let drained = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseEncrypted) >= 3 }, + { outbound.count(ofType: .noiseEncrypted) >= 2 }, timeout: TestConstants.longTimeout ) try #require(drained) @@ -744,11 +771,11 @@ struct BLEServiceCoreTests { 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.count == 2) #expect( plaintexts.filter { $0.first == NoisePayloadType.authenticatedPeerState.rawValue - }.count == 1 + }.isEmpty ) #expect( plaintexts.filter { @@ -760,6 +787,13 @@ struct BLEServiceCoreTests { $0.first == NoisePayloadType.groupInvite.rawValue }.count == 1 ) + #expect(outbound.count(ofType: .announce) == 0) + + // A duplicate ready callback cannot replay either buffer. + ble._test_reconcileCurrentNoiseSession(for: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + #expect(outbound.count(ofType: .noiseEncrypted) == 2) + #expect(outbound.count(ofType: .announce) == 0) } /// A legitimate rotation announce necessarily arrives on a link still @@ -1234,6 +1268,7 @@ private final class PublicCaptureDelegate: BitchatDelegate { defer { lock.unlock() } return publicMessages } + } @MainActor diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 7384613c..8d5ae12e 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -1,4 +1,5 @@ import BitFoundation +import CryptoKit import Foundation import Testing @testable import bitchat @@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests { var handshakeAuthenticated = false var hasSession = false let sessionGeneration = UUID() + var awaitingResponderHandshake = false var decryptResult: Result = .success(Data()) + var currentDate = Date(timeIntervalSince1970: 1_000) + var transportGenerationReady = false + var forcedServiceDecryptError: Error? var processedHandshakes: [(peerID: PeerID, message: Data)] = [] var hasSessionQueries: [PeerID] = [] @@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests { recorder: Recorder, now: Date = Date(timeIntervalSince1970: 1_000) ) -> BLENoisePacketHandler { + recorder.currentDate = now let environment = BLENoisePacketHandlerEnvironment( localPeerID: { [localPeerID] in localPeerID }, localPeerIDData: { [localPeerIDData] in localPeerIDData }, messageTTL: TransportConfig.messageTTLDefault, - now: { now }, + now: { recorder.currentDate }, processHandshakeMessage: { peerID, message in recorder.processedHandshakes.append((peerID, message)) return NoiseHandshakeProcessingResult( @@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests { recorder.hasSessionQueries.append(peerID) return recorder.hasSession }, + isAwaitingResponderHandshakeCompletion: { _ in + recorder.awaitingResponderHandshake + }, initiateHandshake: { peerID in recorder.initiatedHandshakes.append(peerID) recorder.events.append("initiateHandshake") @@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests { return BLENoisePacketHandler(environment: environment) } + private func makeServiceBackedHandler( + service: NoiseEncryptionService, + localPeerID: PeerID, + recorder: Recorder, + transportGenerationIsReady: + @escaping (UUID) -> Bool + ) -> BLENoisePacketHandler { + BLENoisePacketHandler( + environment: BLENoisePacketHandlerEnvironment( + localPeerID: { localPeerID }, + localPeerIDData: { + Data(hexString: localPeerID.id) ?? Data() + }, + messageTTL: TransportConfig.messageTTLDefault, + now: { recorder.currentDate }, + processHandshakeMessage: { peerID, message in + try service.processHandshakeMessageWithResult( + from: peerID, + message: message + ) + }, + hasNoiseSession: { peerID in + service.hasSession(with: peerID) + }, + isAwaitingResponderHandshakeCompletion: { peerID in + service.isAwaitingResponderHandshakeCompletion( + with: peerID + ) + }, + initiateHandshake: { peerID in + recorder.initiatedHandshakes.append(peerID) + }, + broadcastPacket: { packet in + recorder.broadcastPackets.append(packet) + }, + updatePeerLastSeen: { peerID in + recorder.lastSeenUpdates.append(peerID) + }, + decrypt: { payload, peerID in + recorder.decryptCalls.append((payload, peerID)) + if let error = recorder.forcedServiceDecryptError { + throw error + } + let result = + try service.decryptWithSessionGeneration( + payload, + from: peerID, + establishedGenerationIsReady: + transportGenerationIsReady + ) + return BLENoiseDecryptionResult( + plaintext: result.plaintext, + sessionGeneration: result.sessionGeneration + ) + }, + clearSession: { peerID in + recorder.clearedSessions.append(peerID) + service.clearSession(for: peerID) + }, + handleAuthenticatedPeerState: { + peerID, payload, generation in + recorder.authenticatedPeerStates.append( + (peerID, payload, generation) + ) + }, + deliverNoisePayload: { + peerID, type, payload, timestamp in + recorder.deliveries.append( + (peerID, type, payload, timestamp) + ) + } + ) + ) + } + + private func establishedServices() throws -> ( + sender: NoiseEncryptionService, + receiver: NoiseEncryptionService, + senderPeerID: PeerID, + receiverPeerID: PeerID + ) { + let sender = NoiseEncryptionService(keychain: MockKeychain()) + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let senderPeerID = PeerID( + publicKey: sender.getStaticPublicKeyData() + ) + let receiverPeerID = PeerID( + publicKey: receiver.getStaticPublicKeyData() + ) + let message1 = try sender.initiateHandshake(with: receiverPeerID) + let message2 = try #require( + try receiver.processHandshakeMessage( + from: senderPeerID, + message: message1 + ) + ) + let message3 = try #require( + try sender.processHandshakeMessage( + from: receiverPeerID, + message: message2 + ) + ) + _ = try receiver.processHandshakeMessage( + from: senderPeerID, + message: message3 + ) + return ( + sender, + receiver, + senderPeerID, + receiverPeerID + ) + } + // MARK: Handshake @Test @@ -364,6 +487,745 @@ struct BLENoisePacketHandlerTests { #expect(recorder.deliveries.isEmpty) } + @Test + func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE]) + ) + handler.handleSessionAuthenticated(remotePeerID) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce() + throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID( + publicKey: alice.getStaticPublicKeyData() + ) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + let message1 = try alice.initiateHandshake(with: bobPeerID) + let message2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + let message3 = try #require( + try alice.processHandshakeMessage( + from: bobPeerID, + message: message2 + ) + ) + let typedPayload = Data([ + NoisePayloadType.privateMessage.rawValue, + 0xCA, 0xFE + ]) + let ciphertext = try alice.encrypt( + typedPayload, + for: bobPeerID + ) + + // Manager promotion has completed, but the serialized BLE callback is + // deliberately still behind this ciphertext. + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: message3 + ) + let recorder = Recorder() + recorder.transportGenerationReady = false + let handler = makeServiceBackedHandler( + service: bob, + localPeerID: bobPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let packet = makeEncryptedPacket( + recipientID: Data(hexString: bobPeerID.id), + payload: ciphertext + ) + + handler.handleEncrypted(packet, from: alicePeerID) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + + // The exact ciphertext must still authenticate, proving the readiness + // rejection happened before the receive nonce was consumed. + recorder.transportGenerationReady = true + handler.handleSessionAuthenticated(alicePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce() + 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()) + + let initial1 = try alice.initiateHandshake(with: bobPeerID) + let initial2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: initial1 + ) + ) + let initial3 = try #require( + try alice.processHandshakeMessage( + from: bobPeerID, + message: initial2 + ) + ) + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: initial3 + ) + let typedPayload = Data([ + NoisePayloadType.privateMessage.rawValue, + 0xBE, 0xEF + ]) + let delayedCiphertext = try alice.encrypt( + typedPayload, + for: bobPeerID + ) + + let forged1 = try mallory.initiateHandshake(with: bobPeerID) + let forged2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: forged1 + ) + ) + let forged3 = try #require( + try mallory.processHandshakeMessage( + from: bobPeerID, + message: forged2 + ) + ) + #expect(throws: NoiseSessionError.peerIdentityMismatch) { + try bob.processHandshakeMessage( + from: alicePeerID, + message: forged3 + ) + } + #expect(bob.hasEstablishedSession(with: alicePeerID)) + + let recorder = Recorder() + recorder.transportGenerationReady = false + let handler = makeServiceBackedHandler( + service: bob, + localPeerID: bobPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let packet = makeEncryptedPacket( + recipientID: Data(hexString: bobPeerID.id), + payload: delayedCiphertext + ) + + // Manager rollback is visible, while the BLE restore callback is + // deliberately still queued behind this ciphertext. + handler.handleEncrypted(packet, from: alicePeerID) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + + recorder.transportGenerationReady = true + handler.handleSessionAuthenticated(alicePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func oversizedCiphertextCannotEvictEstablishedTransport() throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: Data( + count: + NoiseSecurityConstants + .maxPrivateFileCiphertextSize + 1 + ) + ), + from: pair.senderPeerID + ) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + let valid = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x01]), + for: pair.receiverPeerID + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: valid + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.payload == Data([0x01])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func forgedAuthenticationFailureCannotEvictEstablishedTransport() + throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let valid = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x02]), + for: pair.receiverPeerID + ) + var forged = valid + forged[forged.index(before: forged.endIndex)] ^= 0xFF + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: forged + ), + from: pair.senderPeerID + ) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + // Authentication failure leaves nonce state untouched, so the exact + // original ciphertext remains valid. + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: valid + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.payload == Data([0x02])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let first = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x03]), + for: pair.receiverPeerID + ) + let firstPacket = makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: first + ) + + handler.handleEncrypted(firstPacket, from: pair.senderPeerID) + handler.handleEncrypted(firstPacket, from: pair.senderPeerID) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + let next = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x04]), + for: pair.receiverPeerID + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: next + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 2) + #expect(recorder.deliveries.map { $0.payload } == [ + Data([0x03]), Data([0x04]) + ]) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce() + throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + recorder.forcedServiceDecryptError = + NoiseSecurityError.rateLimitExceeded + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let valid = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x05]), + for: pair.receiverPeerID + ) + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: Data(repeating: 0xA5, count: 20) + ), + from: pair.senderPeerID + ) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + recorder.forcedServiceDecryptError = nil + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: valid + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.payload == Data([0x05])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: NoiseSecurityConstants.maxPrivateFileCiphertextSize + ) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.privateFile.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateFile) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func oversizedEarlyCiphertextIsNotDeferred() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: + NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1 + ) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + NoiseEncryptionError.sessionNotEstablished + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.initiatedHandshakes.isEmpty) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure(NoiseError.replayDetected) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.readReceipt.rawValue, 0x02]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .readReceipt) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure(TestError()) + let handler = makeHandler(recorder: recorder) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ), + from: remotePeerID + ) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextBufferIsBoundedPerPeer() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + + for index in 0..<5 { + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + timestamp: UInt64(900_000 + index) + ), + from: remotePeerID + ) + } + #expect(recorder.decryptCalls.count == 5) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 9) + #expect(recorder.deliveries.count == 4) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextBufferIsBoundedGlobally() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let peers = (1...33).map { + PeerID(str: String(format: "%016llx", UInt64($0))) + } + let packet = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + for peerID in peers { + handler.handleEncrypted(packet, from: peerID) + } + #expect(recorder.decryptCalls.count == 33) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + for peerID in peers { + handler.handleSessionAuthenticated(peerID) + } + + #expect(recorder.decryptCalls.count == 65) + #expect(recorder.deliveries.count == 32) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let peers = [ + PeerID(str: "0000000000000001"), + PeerID(str: "0000000000000002"), + PeerID(str: "0000000000000003") + ] + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: + NoiseSecurityConstants.maxPrivateFileCiphertextSize + ) + ), + from: peers[0] + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data(count: 256 * 1024) + ), + from: peers[1] + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data([0x01]) + ), + from: peers[2] + ) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + for peerID in peers { + handler.handleSessionAuthenticated(peerID) + } + + #expect(recorder.decryptCalls.count == 5) + #expect(recorder.deliveries.count == 2) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func expiredEarlyCiphertextIsNotRetried() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ), + from: remotePeerID + ) + + recorder.currentDate = + recorder.currentDate.addingTimeInterval( + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + + 0.001 + ) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextSurvivesResponderHandshakeWindow() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ), + from: remotePeerID + ) + + recorder.currentDate = + recorder.currentDate.addingTimeInterval( + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + - 0.001 + ) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket { BitchatPacket( type: MessageType.noiseHandshake.rawValue, @@ -378,14 +1240,15 @@ struct BLENoisePacketHandlerTests { private func makeEncryptedPacket( recipientID: Data?, - timestamp: UInt64 = 900_000 + timestamp: UInt64 = 900_000, + payload: Data = Data([0xC0, 0xFF, 0xEE]) ) -> BitchatPacket { BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: Data(hexString: remotePeerID.id) ?? Data(), recipientID: recipientID, timestamp: timestamp, - payload: Data([0xC0, 0xFF, 0xEE]), + payload: payload, signature: nil, ttl: TransportConfig.messageTTLDefault ) diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 8ef7d71c..e34a5c1e 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -1344,6 +1344,89 @@ struct NoiseEncryptionServiceTests { } } + @Test("Responder completion state spans ordinary XX message three") + func responderCompletionStateTracksOrdinaryHandshake() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID( + publicKey: alice.getStaticPublicKeyData() + ) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + let message1 = try alice.initiateHandshake(with: bobPeerID) + #expect( + !bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID) + ) + + let message2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + #expect( + bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID) + ) + + let message3 = try #require( + try alice.processHandshakeMessage( + from: bobPeerID, + message: message2 + ) + ) + #expect( + bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID) + ) + + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: message3 + ) + #expect( + !bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID) + ) + } + + @Test("Transport readiness rejection spends no message budget") + func transportReadinessRejectionSpendsNoMessageBudget() 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 plaintext = Data([ + NoisePayloadType.privateMessage.rawValue, + 0xAB, 0xCD + ]) + let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) + + for _ in 0...NoiseSecurityConstants.maxMessagesPerSecond { + do { + _ = try bob.decryptWithSessionGeneration( + ciphertext, + from: alicePeerID, + establishedGenerationIsReady: { _ in false } + ) + Issue.record( + "Expected transport generation readiness rejection" + ) + } catch NoiseEncryptionError.transportGenerationNotReady { + // Expected: authorization and nonce mutation are both later. + } catch { + Issue.record("Unexpected readiness error: \(error)") + } + } + + let decrypted = try bob.decryptWithSessionGeneration( + ciphertext, + from: alicePeerID, + establishedGenerationIsReady: { _ in true } + ) + #expect(decrypted.plaintext == plaintext) + } + @Test("NoiseMessage JSON and binary encoding round-trip") func noiseMessageRoundTrips() throws { let message = NoiseMessage(