From 7bba0309376a7dabf65540ea7a221bb6a2f3fd3b Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 20:22:23 +0200 Subject: [PATCH] Discard deferred Noise ciphertext during panic --- .../Services/BLE/BLENoisePacketHandler.swift | 10 ++++ bitchat/Services/BLE/BLEService.swift | 8 ++- .../Services/BLENoisePacketHandlerTests.swift | 54 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index e647f96c..9314f666 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -184,6 +184,16 @@ final class BLENoisePacketHandler { drainDeferredCiphertextsIfReady(for: peerID) } + /// Synchronously discards ciphertext retained for a pre-panic Noise + /// generation. The handler survives the service's identity replacement, + /// so keeping this queue would replay old bytes after post-panic auth. + func resetForPanic() { + deferredLock.lock() + deferredCiphertexts.removeAll(keepingCapacity: false) + deferredCiphertextBytes = 0 + deferredLock.unlock() + } + private func handleEncrypted( _ packet: BitchatPacket, from peerID: PeerID, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index c8622934..fd300a3c 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -707,6 +707,7 @@ final class BLEService: NSObject { /// or advertising while the full panic transaction is incomplete. func suspendForPanicReset() { setPanicSuspended(true) + noisePacketHandler.resetForPanic() gossipSyncManager?.stop() gossipSyncManager = nil // Stop the radio and drain CoreBluetooth's delegate queue first. A @@ -717,7 +718,11 @@ final class BLEService: NSObject { // Drain every receive/send submitted by callbacks that finished ahead // of the radio stop. Later callbacks observe the closed lifecycle, and // generation-bound handoffs that raced this barrier reject themselves. - messageQueue.sync(flags: .barrier) {} + // Clear the old identity's bounded early-ciphertext queue again after + // those callbacks drain so none can repopulate it after the first wipe. + messageQueue.sync(flags: .barrier) { + noisePacketHandler.resetForPanic() + } clearEmergencySessionState() } @@ -736,6 +741,7 @@ final class BLEService: NSObject { gossipSyncManager?.stop() gossipSyncManager = nil messageQueue.sync(flags: .barrier) { + noisePacketHandler.resetForPanic() pendingNoiseSessionQueues.removeAll() } diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 8d5ae12e..cdfa6f81 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -521,6 +521,60 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let prePanicCiphertext = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + ) + ) + + handler.handleEncrypted(prePanicCiphertext, from: remotePeerID) + #expect(recorder.decryptCalls.count == 1) + + handler.resetForPanic() + + // Three maximum-sized packets fit only when reset also zeroed the + // global byte accounting. They model ciphertext received under the + // replacement identity before that responder handshake completes. + for index in 0..<3 { + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + timestamp: UInt64(901_000 + index), + payload: Data( + count: NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + ) + ), + from: remotePeerID + ) + } + #expect(recorder.decryptCalls.count == 4) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + // Only the three post-reset packets replay; the pre-panic packet does + // not survive into the replacement session. + #expect(recorder.decryptCalls.count == 7) + #expect(recorder.deliveries.count == 3) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + @Test func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce() throws {