From 02828731b9466368396c0d30987fcc25e4e4170a Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 6 Jul 2026 14:34:46 +0200 Subject: [PATCH] Drop couriered mail from blocked senders at envelope open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI-layer block check (isPeerBlocked in the transport event coordinator) resolves a fingerprint from the live session or peer list, but a couriered message arrives precisely when its sender is absent — no session, no registry entry — so the check failed open and a blocked identity's mail was delivered anyway. Gate in openCourierEnvelope, where the sealed sender's full static key is in hand. End-to-end test ferries a full deposit→carry→handover round and verifies the envelope from a blocked sender never reaches the delegate (confirmed failing without the gate). Co-Authored-By: Claude Fable 5 --- bitchat/Services/BLE/BLEService.swift | 7 ++ .../EndToEnd/CourierEndToEndTests.swift | 73 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 45f2fd10..c8362329 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2570,6 +2570,13 @@ extension BLEService { SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) return } + // Couriered mail arrives while the sender is absent, so the UI's + // block check can't resolve their fingerprint from a live session. + // Gate here, where the full static key is in hand. + guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else { + SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security) + return + } // Mesh peer IDs are derived from Noise static keys, so the sender // resolves to the same DM thread whether or not they're present. let senderPeerID = PeerID(publicKey: senderStaticKey) diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 971c2ac8..fa948f4b 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -67,9 +67,9 @@ struct CourierEndToEndTests { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} } - private func makeService() -> BLEService { + private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService { let keychain = MockKeychain() - let identityManager = MockIdentityManager(keychain) + let identityManager = identityManager ?? MockIdentityManager(keychain) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let service = BLEService( keychain: keychain, @@ -177,6 +177,75 @@ struct CourierEndToEndTests { #expect(message.content == "the camp moved north") } + @Test func courieredMailFromBlockedSenderIsDropped() async throws { + let alice = makeService() + let carol = makeService() + let bobIdentity = MockIdentityManager(MockKeychain()) + let bob = makeService(identityManager: bobIdentity) + carol.courierDepositPolicy = { _ in true } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + // Bob blocked Alice by her stable Noise identity while she was away. + bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true) + + #expect(alice.sendCourierMessage( + "you should not see this", + messageID: "courier-msg-blocked-sender", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let announcePacket = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + + // Bob opens the envelope — but the sealed sender is blocked, and it + // must never reach the UI. The live block check can't cover this: the + // sender is absent from Bob's registry, so no fingerprint resolves at + // delivery time. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let delivered = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!delivered) + } + @Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws { let alice = makeService() let carol = makeService()