Preserve early Noise ciphertext across reconnect promotion (#1464)

Companion to #1463 (they were built as a unit): adds a bounded deferral buffer (4/peer, 32 global, byte-budgeted, responder-timeout lifetime) for ciphertext that arrives before XX message 3 or before BLE installs generation-bound transport state, retried exactly once after the serialized authentication/restore callback. Reclassifies malformed/forged/replayed/oversized/rate-limited ciphertext as drop-only, so attacker bytes can no longer evict an established session (on main, a 1-byte forgery evicts the session via the old clearSession + re-handshake path). Serializes Noise packet reception as messageQueue barriers.

Honors #1463's timeout-restore deferral through the same-generation ready path (verified by negative test: without the gate, parked DMs drain under discarded keys). Full local suite 1789 tests green.
This commit is contained in:
jack
2026-07-26 13:30:06 +02:00
committed by GitHub
parent 78a81e5b57
commit e3e97d51ec
7 changed files with 1458 additions and 40 deletions
+46 -7
View File
@@ -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())
@@ -666,6 +666,10 @@ struct BLEServiceCoreTests {
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID))
// The establishment transition enqueues its forced announce as a
// separate serialized phase; drain again so it lands before the tap
// and cannot masquerade as restore-driven announce traffic below.
await ble._test_drainNoiseMessagePipeline()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
@@ -722,21 +726,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 +775,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 +791,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)
}
/// The message-loss interleaving behind a legitimate peer restart: the
@@ -1494,6 +1532,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
defer { lock.unlock() }
return publicMessages
}
}
@MainActor