diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index a4628635..e33248e3 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -59,9 +59,8 @@ struct BLEAnnounceHandlerEnvironment { let scheduleAfterglow: (TimeInterval) -> Void } -/// Orchestrates inbound announce packets: preflight validation, signature -/// trust, registry/topology updates, identity persistence, UI notification, -/// gossip tracking, and the reciprocal announce response. +/// Outcome of an accepted announce, surfaced so the service can run +/// follow-up work (e.g. courier handover) that keys off the announce. struct BLEAnnounceHandlingResult { let peerID: PeerID let announcement: AnnouncementPacket @@ -69,6 +68,9 @@ struct BLEAnnounceHandlingResult { let isVerified: Bool } +/// Orchestrates inbound announce packets: preflight validation, signature +/// trust, registry/topology updates, identity persistence, UI notification, +/// gossip tracking, and the reciprocal announce response. final class BLEAnnounceHandler { private let environment: BLEAnnounceHandlerEnvironment diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index dfc1ec22..227e003f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3172,9 +3172,13 @@ extension BLEService { // Courier handover: an announce is the moment we learn a peer's Noise // static key, so check whether we're carrying mail addressed to them. + // Direct announces only: envelopes are removed from the store + // optimistically, so handover must ride an established link rather + // than a speculative multi-hop send toward a relayed announce. guard !courierStore.isEmpty, let result, - result.isVerified else { return } + result.isVerified, + result.isDirectAnnounce else { return } deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey) } diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 24bbc1b4..e5523257 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -54,9 +54,11 @@ final class CourierStore { private let fileURL: URL? private let now: () -> Date - init(persistsToDisk: Bool = true, now: @escaping () -> Date = Date.init) { + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) { self.now = now - self.fileURL = persistsToDisk ? Self.defaultFileURL() : nil + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil loadFromDisk() } diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index 162bea58..9e675649 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -158,16 +158,19 @@ struct CourierStoreTests { // MARK: - Persistence @Test func persistsAndReloadsAcrossInstances() throws { - // Uses the real on-disk location; clean up around the test. - let first = CourierStore(persistsToDisk: true, now: { Self.baseDate }) - defer { first.wipe() } - first.wipe() + // Isolated on-disk location so the test never touches the real store. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("envelopes.json") + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) let recipientKey = Data(repeating: 0xE0, count: 32) let envelope = makeEnvelope(recipientKey: recipientKey) #expect(first.deposit(envelope, from: depositorA)) - let second = CourierStore(persistsToDisk: true, now: { Self.baseDate }) + let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) #expect(second.takeEnvelopes(for: recipientKey) == [envelope]) } } diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 7a5518df..971c2ac8 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -38,6 +38,11 @@ struct CourierEndToEndTests { lock.lock(); defer { lock.unlock() } return packets.filter { $0.type == type.rawValue }.count } + + func all(ofType type: MessageType) -> [BitchatPacket] { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue } + } } private final class NoiseCaptureDelegate: BitchatDelegate { @@ -234,6 +239,87 @@ struct CourierEndToEndTests { #expect(carol.courierStore.isEmpty) } + @Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _ in true } + + 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) + + #expect(alice.sendCourierMessage( + "hold for a direct encounter", + messageID: "courier-msg-relayed-announce", + 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 directAnnounce = try #require(bobOut.first(ofType: .announce)) + + // A relayed copy has a decremented TTL but a still-valid signature + // (TTL is excluded from announce signatures). Envelopes are removed + // from the store optimistically, so handover must wait for a direct + // encounter instead of chasing a multi-hop path. + var relayedAnnounce = directAnnounce + relayedAnnounce.ttl = directAnnounce.ttl - 1 + carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let leakedOnRelayedAnnounce = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!leakedOnRelayedAnnounce) + #expect(!carol.courierStore.isEmpty) + + // The relayed copy consumed the original announce's dedup key + // (sender/timestamp/payload — TTL excluded), so the direct handover + // needs a fresh announce. Wait out the 1s announce throttle first. + try await Task.sleep(nanoseconds: 1_100_000_000) + bob.sendBroadcastAnnounce() + let reannounced = await TestHelpers.waitUntil( + { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } }, + timeout: TestConstants.defaultTimeout + ) + #expect(reannounced) + let freshAnnounce = try #require( + bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp } + ) + carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) == 1 }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + #expect(carol.courierStore.isEmpty) + } + @Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws { let alice = makeService() let carol = makeService()