Drop couriered mail from blocked senders at envelope open

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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 14:34:46 +02:00
co-authored by Claude Fable 5
parent f5b2150f11
commit 02828731b9
2 changed files with 78 additions and 2 deletions
+7
View File
@@ -2570,6 +2570,13 @@ extension BLEService {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return 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 // Mesh peer IDs are derived from Noise static keys, so the sender
// resolves to the same DM thread whether or not they're present. // resolves to the same DM thread whether or not they're present.
let senderPeerID = PeerID(publicKey: senderStaticKey) let senderPeerID = PeerID(publicKey: senderStaticKey)
@@ -67,9 +67,9 @@ struct CourierEndToEndTests {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} 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 keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain) let identityManager = identityManager ?? MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = BLEService( let service = BLEService(
keychain: keychain, keychain: keychain,
@@ -177,6 +177,75 @@ struct CourierEndToEndTests {
#expect(message.content == "the camp moved north") #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 { @Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService() let alice = makeService()
let carol = makeService() let carol = makeService()