From 2c04f65c83186216501268394d39dda89d1f2de4 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 17 Jun 2026 10:04:11 +0200 Subject: [PATCH] Fix courier handoff verification and directed sends --- bitchat/Services/BLE/BLEAnnounceHandler.swift | 25 +++- bitchat/Services/BLE/BLEFanoutSelector.swift | 70 ++++++++++- bitchat/Services/BLE/BLEService.swift | 66 ++++++----- .../EndToEnd/CourierEndToEndTests.swift | 111 +++++++++++++++++- .../Services/BLEAnnounceHandlerTests.swift | 30 +++-- .../Services/BLEFanoutSelectorTests.swift | 73 ++++++++++++ 6 files changed, 323 insertions(+), 52 deletions(-) diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index fbd594a2..a4628635 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -62,6 +62,13 @@ struct BLEAnnounceHandlerEnvironment { /// Orchestrates inbound announce packets: preflight validation, signature /// trust, registry/topology updates, identity persistence, UI notification, /// gossip tracking, and the reciprocal announce response. +struct BLEAnnounceHandlingResult { + let peerID: PeerID + let announcement: AnnouncementPacket + let isDirectAnnounce: Bool + let isVerified: Bool +} + final class BLEAnnounceHandler { private let environment: BLEAnnounceHandlerEnvironment @@ -69,7 +76,8 @@ final class BLEAnnounceHandler { self.environment = environment } - func handle(_ packet: BitchatPacket, from peerID: PeerID) { + @discardableResult + func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? { let env = environment let now = env.now() let preflight = BLEAnnouncePreflightPolicy.evaluate( @@ -85,15 +93,15 @@ final class BLEAnnounceHandler { announcement = acceptance.announcement case .reject(.malformed): SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) - return + return nil case .reject(.senderMismatch(let derivedFromKey)): SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) - return + return nil case .reject(.selfAnnounce): - return + return nil case .reject(.stale(let ageSeconds)): SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return + return nil } // Suppress announce logs to reduce noise @@ -210,5 +218,12 @@ final class BLEAnnounceHandler { let delay = Double.random(in: 0.3...0.6) env.scheduleAfterglow(delay) } + + return BLEAnnounceHandlingResult( + peerID: peerID, + announcement: announcement, + isDirectAnnounce: isDirectAnnounce, + isVerified: verifiedAnnounce + ) } } diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift index aa99a8f1..6cabcdd2 100644 --- a/bitchat/Services/BLE/BLEFanoutSelector.swift +++ b/bitchat/Services/BLE/BLEFanoutSelector.swift @@ -19,13 +19,35 @@ enum BLEFanoutSelector { packetType: UInt8, messageID: String ) -> BLEFanoutSelection { + let rawAllowed = allowedLinks( + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressLink, + excludedLinks: excludedLinks + ) + + if let directedPeerHint, + let directedSelection = directLinks( + to: directedPeerHint, + links: rawAllowed, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) { + return directedSelection + } + if let directedPeerHint, + hasBoundLink( + to: directedPeerHint, + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) { + return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) + } + let allowed = collapseDuplicateLinksPerPeer( - allowedLinks( - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressLink, - excludedLinks: excludedLinks - ), + rawAllowed, peripheralPeerBindings: peripheralPeerBindings, centralPeerBindings: centralPeerBindings ) @@ -71,6 +93,42 @@ enum BLEFanoutSelector { return (allowedPeripheralIDs, allowedCentralIDs) } + private static func directLinks( + to peerID: PeerID, + links: (peripheralIDs: [String], centralIDs: [String]), + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID] + ) -> BLEFanoutSelection? { + let directLinks = collapseDuplicateLinksPerPeer( + ( + peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID }, + centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } + ), + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) + + guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { + return nil + } + + return BLEFanoutSelection( + peripheralIDs: Set(directLinks.peripheralIDs), + centralIDs: Set(directLinks.centralIDs) + ) + } + + private static func hasBoundLink( + to peerID: PeerID, + peripheralIDs: [String], + centralIDs: [String], + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID] + ) -> Bool { + peripheralIDs.contains { peripheralPeerBindings[$0] == peerID } + || centralIDs.contains { centralPeerBindings[$0] == peerID } + } + // Dual-role pairs hold two live links (we-as-central writing to their // peripheral, and they-as-central subscribed to ours). Sending the same // packet down both doubles airtime for nothing — the receiver's assembler diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 26a1d175..ecb1f45a 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1065,6 +1065,9 @@ final class BLEService: NSObject { // Directed send helper (unicast to a specific peerID) without altering packet contents private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { + #if DEBUG + _test_onOutboundPacket?(packet) + #endif guard let data = packet.toBinaryData(padding: false) else { return } sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) } @@ -2491,33 +2494,39 @@ extension BLEService { /// Seal `content` to the recipient's static key (one-way Noise X) and hand /// the envelope to the given couriers for physical delivery. Returns false - /// when no courier is connected; sealing and sending happen asynchronously. + /// when no courier is connected, the payload cannot be built, or sealing + /// fails; link writes are queued asynchronously after the envelope is ready. func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { let connected = couriers.filter { isPeerConnected($0) } guard !connected.isEmpty, let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { return false } + + let payload: Data + do { + let now = Date() + let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientNoiseKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), + ciphertext: sealed + ) + guard let encoded = envelope.encode() else { return false } + payload = encoded + } catch { + SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption) + return false + } + messageQueue.async { [weak self] in guard let self else { return } - do { - let now = Date() - let sealed = try self.noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) - let envelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: recipientNoiseKey, - epochDay: CourierEnvelope.epochDay(for: now) - ), - expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), - ciphertext: sealed - ) - guard let payload = envelope.encode() else { return } - for courier in connected { - SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - self.broadcastPacket(self.makeCourierPacket(payload, to: courier)) - } - } catch { - SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption) + for courier in connected { + SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier) } } return true @@ -2605,7 +2614,7 @@ extension BLEService { SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session) for envelope in envelopes { guard let payload = envelope.encode() else { continue } - broadcastPacket(makeCourierPacket(payload, to: peerID)) + sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID) } } @@ -3159,21 +3168,14 @@ extension BLEService { } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { - announceHandler.handle(packet, from: peerID) + let result = announceHandler.handle(packet, from: peerID) // 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. - // Re-run the preflight so handover only follows announces that pass - // the key<->peerID binding check (mirrors the handler's own gate). - guard !courierStore.isEmpty else { return } - if case .accept(let acceptance) = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: myPeerID, - now: Date() - ) { - deliverCourierMail(to: peerID, noiseKey: acceptance.announcement.noisePublicKey) - } + guard !courierStore.isEmpty, + let result, + result.isVerified else { return } + deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey) } /// Builds the announce handler environment. All queue hops stay here so diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 1383c00d..4d085990 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -33,6 +33,11 @@ struct CourierEndToEndTests { lock.lock(); defer { lock.unlock() } return packets.first { $0.type == type.rawValue } } + + func count(ofType type: MessageType) -> Int { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue }.count + } } private final class NoiseCaptureDelegate: BitchatDelegate { @@ -139,7 +144,7 @@ struct CourierEndToEndTests { ) #expect(announced) let announcePacket = try #require(bobOut.first(ofType: .announce)) - carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID) + carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, @@ -167,6 +172,90 @@ struct CourierEndToEndTests { #expect(message.content == "the camp moved north") } + @Test func unverifiedAnnounceDoesNotTriggerCourierHandover() 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 until verified", + messageID: "courier-msg-unverified-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) + + let forgedAnnounce = try makeUnsignedAnnounce(from: bob) + carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!leakedOnUnverifiedAnnounce) + #expect(!carol.courierStore.isEmpty) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let verifiedAnnounce = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(verifiedAnnounce, 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() + preseedConnectedPeer(carol, in: alice) + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + + #expect(!alice.sendCourierMessage( + "this cannot be sealed", + messageID: "courier-msg-invalid-key", + recipientNoiseKey: Data(repeating: 0x01, count: 8), + via: [carol.myPeerID] + )) + + let queuedPacket = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.shortTimeout + ) + #expect(!queuedPacket) + } + @Test func depositFromUntrustedPeerIsRejected() async throws { let carol = makeService() carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite @@ -202,6 +291,26 @@ struct CourierEndToEndTests { ) #expect(!stored) } + + private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Unsigned", + noisePublicKey: service.noiseStaticPublicKeyData(), + signingPublicKey: service.noiseSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + + return BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } } // MARK: - Router courier selection diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 80944ed4..d53a50b3 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests { recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.peerID == peerID) + #expect(result?.announcement.noisePublicKey == noiseKey) + #expect(result?.isDirectAnnounce == true) + #expect(result?.isVerified == true) #expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) #expect(recorder.barrierCount == 1) @@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.peerID == peerID) + #expect(result?.announcement.noisePublicKey == noiseKey) + #expect(result?.isVerified == false) #expect(recorder.verifySignatureCalls.isEmpty) #expect(recorder.barrierCount == 1) #expect(recorder.upsertCalls.isEmpty) @@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests { recorder.signatureValid = false let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isVerified == false) #expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) @@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests { recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isDirectAnnounce == false) + #expect(result?.isVerified == true) #expect(recorder.upsertCalls.count == 1) #expect(recorder.upsertCalls.first?.isConnected == false) #expect(recorder.uiEventDeliveries.count == 1) @@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests { recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isVerified == false) #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) diff --git a/bitchatTests/Services/BLEFanoutSelectorTests.swift b/bitchatTests/Services/BLEFanoutSelectorTests.swift index 43146365..deec44f5 100644 --- a/bitchatTests/Services/BLEFanoutSelectorTests.swift +++ b/bitchatTests/Services/BLEFanoutSelectorTests.swift @@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests { #expect(selection.centralIDs == Set(["c2"])) } + @Test + func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["target-p", "bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: nil, + peripheralPeerBindings: [ + "target-p": target, + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["target-p"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: nil, + peripheralPeerBindings: [ + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs.isEmpty) + #expect(selection.centralIDs == Set(["target-c"])) + } + + @Test + func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: .central("target-c"), + peripheralPeerBindings: [ + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs.isEmpty) + #expect(selection.centralIDs.isEmpty) + } + @Test func directedSendExcludesAllLinksToIngressPeer() { let selection = BLEFanoutSelector.selectLinks(