Discard deferred Noise ciphertext during panic

This commit is contained in:
jack
2026-07-26 12:55:14 +02:00
committed by jack
parent bf10c7142d
commit 88e5c9f173
3 changed files with 75 additions and 1 deletions
@@ -184,6 +184,16 @@ final class BLENoisePacketHandler {
drainDeferredCiphertextsIfReady(for: peerID) 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( private func handleEncrypted(
_ packet: BitchatPacket, _ packet: BitchatPacket,
from peerID: PeerID, from peerID: PeerID,
+11 -1
View File
@@ -718,6 +718,7 @@ final class BLEService: NSObject {
/// or advertising while the full panic transaction is incomplete. /// or advertising while the full panic transaction is incomplete.
func suspendForPanicReset() { func suspendForPanicReset() {
setPanicSuspended(true) setPanicSuspended(true)
noisePacketHandler.resetForPanic()
gossipSyncManager?.stop() gossipSyncManager?.stop()
gossipSyncManager = nil gossipSyncManager = nil
// Stop the radio and drain CoreBluetooth's delegate queue first. A // Stop the radio and drain CoreBluetooth's delegate queue first. A
@@ -728,7 +729,11 @@ final class BLEService: NSObject {
// Drain every receive/send submitted by callbacks that finished ahead // Drain every receive/send submitted by callbacks that finished ahead
// of the radio stop. Later callbacks observe the closed lifecycle, and // of the radio stop. Later callbacks observe the closed lifecycle, and
// generation-bound handoffs that raced this barrier reject themselves. // 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() clearEmergencySessionState()
} }
@@ -746,6 +751,11 @@ final class BLEService: NSObject {
) { ) {
gossipSyncManager?.stop() gossipSyncManager?.stop()
gossipSyncManager = nil gossipSyncManager = nil
// Discard deferred pre-panic ciphertext behind any in-flight receive
// handlers so none can repopulate the handler's bounded queue.
messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
}
// pendingNoiseSessionQueues is owned by collectionsQueue everywhere // pendingNoiseSessionQueues is owned by collectionsQueue everywhere
// else, so clear it there too rather than on messageQueue. // else, so clear it there too rather than on messageQueue.
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -521,6 +521,60 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty) #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 @Test
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce() func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
throws { throws {