Fix courier handoff verification and directed sends

This commit is contained in:
jack
2026-06-17 10:04:16 +02:00
parent 293380e671
commit 2c04f65c83
6 changed files with 323 additions and 52 deletions
+20 -5
View File
@@ -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
)
}
}
+64 -6
View File
@@ -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
+34 -32
View File
@@ -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
@@ -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
@@ -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)
@@ -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(