diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index e33248e3..d5b73464 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -20,6 +20,12 @@ struct BLEAnnounceHandlerEnvironment { let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) + /// Whether the link this packet arrived on is already bound to a + /// different peer ID (ingress-registry + BLE-queue read). Directness + /// rides on the unsigned TTL, so a replayed announce can look "direct" + /// on the replayer's link; that link must not shortcut an absent peer + /// into "connected". + let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool /// Runs the registry mutation phase under the collections barrier. let withRegistryBarrier: (() -> Void) -> Void /// Upserts the verified announce into the peer registry. @@ -135,6 +141,11 @@ final class BLEAnnounceHandler { var isReconnectedPeer = false let directLinkState = env.linkState(peerID) let isDirectAnnounce = packet.ttl == env.messageTTL + // A "direct" announce arriving on a link that another peer already + // owns is either a rotation heal or a replay with its TTL restored; + // both are ambiguous, so only the rebind (which containment-checks + // the claimed identity) may promote it — never this shortcut. + let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID) env.withRegistryBarrier { let hasPeripheralConnection = directLinkState.hasPeripheral @@ -152,7 +163,7 @@ final class BLEAnnounceHandler { let update = env.upsertVerifiedAnnounce( peerID, announcement, - isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), now ) isNewPeer = update.isNewPeer diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 19eb6a32..d75b1ced 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -674,6 +674,15 @@ final class BLEService: NSObject { } } + func canDeliverSecurely(to peerID: PeerID) -> Bool { + // A live link binding alone is forgeable: the rotation heal rebinds a + // link on a signature-verified "direct" announce, but directness rides + // on the unsigned TTL, so a replayed announce can bind an absent + // peer's ID to the replayer's link. An established Noise session + // proves the other end of the link holds the peer's private key. + noiseService.hasEstablishedSession(with: peerID) + } + func peerNickname(peerID: PeerID) -> String? { collectionsQueue.sync { peerRegistry.nickname(for: peerID, connectedOnly: true) @@ -4430,6 +4439,20 @@ extension BLEService { linkState: { [weak self] peerID in self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) }, + linkBoundToOtherPeer: { [weak self] packet, peerID in + guard let self else { return false } + guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false } + let boundPeerID: PeerID? = self.readLinkState { store in + switch link { + case .peripheral(let peripheralUUID): + return store.peerID(forPeripheralID: peripheralUUID) + case .central(let centralUUID): + return store.peerID(forCentralUUID: centralUUID) + } + } + guard let boundPeerID else { return false } + return boundPeerID != peerID + }, withRegistryBarrier: { [weak self] body in self?.collectionsQueue.sync(flags: .barrier) { body() } }, diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index f8474776..ede20949 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -161,14 +161,31 @@ final class MessageRouter { // MARK: - Message Sending func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - if let transport = connectedTransport(for: peerID) { - // A live link is a strong delivery signal; trust it outright. + if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { + // A live link that can complete an encrypted delivery is a + // strong delivery signal; trust it outright. SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) return } let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1) + if let transport = connectedTransport(for: peerID) { + // "Connected" without an established secure session is forgeable: + // link bindings heal on signature-verified "direct" announces, but + // directness rides on the unsigned TTL, so a replayed announce can + // bind an absent peer's ID to the replayer's link — where the send + // stalls on a handshake the replayer can never complete. Send now + // (a genuine link finishes the handshake and delivers), but retain + // a copy and hand a sealed copy to couriers so nothing is silently + // lost; receivers dedup resends by message ID. + SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + enqueue(message, for: peerID) + attemptCourierDeposit(messageID: messageID, for: peerID) + return + } + if let transport = reachableTransport(for: peerID) { // Reachability without a connection is a freshness heuristic (e.g. // the mesh retention window), so the send can silently go nowhere. @@ -386,14 +403,16 @@ final class MessageRouter { continue } - if let transport = connectedTransport(for: peerID) { - // Live link: send and stop retaining. + if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { + // Live link with a secure session: send and stop retaining. SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) - } else if let transport = reachableTransport(for: peerID) { - // Weak signal: send but keep retaining until an ack clears it, - // bounded by attempt count for peers that never ack. + } else if let transport = connectedTransport(for: peerID) ?? reachableTransport(for: peerID) { + // Weak signal (reachability heuristic, or a "connected" link + // without a secure session — possibly a stolen binding from a + // replayed announce): send but keep retaining until an ack + // clears it, bounded by attempt count for peers that never ack. guard message.sendAttempts < Self.maxSendAttempts else { SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) dropMessage(message.messageID, for: peerID) diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index dc8418a8..2d1c380b 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -230,7 +230,14 @@ final class NostrTransport: Transport, @unchecked Sendable { // instead of waiting for internet that may never come. isPeerReachable(peerID) && queue.sync { relaysConnected } } - + + func canDeliverSecurely(to peerID: PeerID) -> Bool { + // Nostr has no link bindings to forge; a known recipient key plus a + // connected relay is the strongest delivery signal it has. The router + // already retains + couriers for Nostr sends, so keep that behavior. + canDeliverPromptly(to: peerID) + } + func peerNickname(peerID: PeerID) -> String? { nil } func getPeerNicknames() -> [PeerID: String] { [:] } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 43ece9ea..ca9d29fb 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -116,6 +116,14 @@ protocol Transport: AnyObject { /// npub as reachable even with no relay connection, where a send only /// joins a queue waiting for internet that may never come. func canDeliverPromptly(to peerID: PeerID) -> Bool + /// Whether a send to this peer can complete an end-to-end encrypted + /// delivery right now (e.g. an established Noise session). Distinct from + /// connectivity: a "connected" link binding alone is forgeable — link + /// bindings heal on signature-verified "direct" announces, but directness + /// rides on the unsigned TTL, so a replayed announce can wear an absent + /// peer's ID on the replayer's link. Routers must not trust a connected + /// link outright without this. + func canDeliverSecurely(to peerID: PeerID) -> Bool func peerNickname(peerID: PeerID) -> String? func getPeerNicknames() -> [PeerID: String] @@ -244,6 +252,10 @@ extension Transport { // straight to the radio; queue-backed transports override this. func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) } + // Transports without a forgeable link-binding layer (everything but the + // BLE mesh) have no stronger delivery signal than prompt delivery. + func canDeliverSecurely(to peerID: PeerID) -> Bool { canDeliverPromptly(to: peerID) } + // Noise identity hooks default to inert for transports that do not carry // Noise sessions (e.g. NostrTransport). func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 6f7d181b..158a4c4b 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -253,6 +253,57 @@ struct BLEServiceCoreTests { #expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID)) } + @Test + func replayedDirectAnnounceForAbsentPeerDoesNotForgeConnectivity() async throws { + // Residual heal-path gap: the victim has NO live link, so the + // identity-owns-a-link containment cannot refuse the rebind. The + // replay may steal the link binding, but it must not mark the absent + // victim connected (TTL — the directness signal — is unsigned), and + // it can never produce a deliverable secure session. + let ble = makeService() + let attackerPeerID = PeerID(str: "1122334455667788") + let attackerLink = "central-attacker-absent-victim" + ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") + ble._test_bindCentral(attackerLink, to: attackerPeerID) + + // The absent victim's fresh signed announce, replayed on the + // attacker's bound link with its direct TTL restored. + let victimSigner = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "victim", + noisePublicKey: victimSigner.getStaticPublicKeyData(), + signingPublicKey: victimSigner.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: victimPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet") + + #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink)) + ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false) + + // The victim becomes known (verified announce) … + let known = await TestHelpers.waitUntil( + { ble.currentPeerSnapshots().map(\.peerID).contains(victimPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(known) + // … but the forged direct TTL must not mark it connected, and the + // link can never deliver securely, so MessageRouter falls through to + // retain + courier instead of trusting the stolen link outright. + #expect(!ble.isPeerConnected(victimPeerID)) + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + } + @Test func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws { let ble = makeService() diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 6c617249..a96687c5 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -50,6 +50,10 @@ final class MockTransport: Transport { var connectedPeers: Set = [] var reachablePeers: Set = [] + /// Peers with an established secure session. `nil` mirrors the protocol + /// default (prompt delivery), so connected peers stay "secure" for tests + /// that never care about the distinction. + var securePeers: Set? var peerNicknames: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] @@ -87,6 +91,10 @@ final class MockTransport: Transport { reachablePeers.contains(peerID) || connectedPeers.contains(peerID) } + func canDeliverSecurely(to peerID: PeerID) -> Bool { + securePeers?.contains(peerID) ?? canDeliverPromptly(to: peerID) + } + func peerNickname(peerID: PeerID) -> String? { peerNicknames[peerID] } diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index 73609823..d295ff4e 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -103,6 +103,9 @@ struct ProtocolContractTests { #expect(probe.sentMessages.count == 1) #expect(probe.sentMessages.first?.content == "hello") #expect(probe.acceptPendingFile(id: "pending") == nil) + // Secure delivery defaults to prompt delivery (itself defaulting to + // reachability) for transports without a forgeable link layer. + #expect(probe.canDeliverSecurely(to: peerID) == false) } @Test diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index d53a50b3..54046cf6 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -8,6 +8,7 @@ struct BLEAnnounceHandlerTests { var existingNoisePublicKey: Data? var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) + var linkBoundToOtherPeer = false var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) var dedupSeenIDs: Set = [] var shouldEmitReconnectLogResult = true @@ -41,6 +42,7 @@ struct BLEAnnounceHandlerTests { return recorder.signatureValid }, linkState: { _ in recorder.linkState }, + linkBoundToOtherPeer: { _, _ in recorder.linkBoundToOtherPeer }, withRegistryBarrier: { body in recorder.barrierCount += 1 body() @@ -334,6 +336,60 @@ struct BLEAnnounceHandlerTests { #expect(recorder.afterglowDelays.count == 1) } + /// TTL is unsigned, so a replayed announce with its TTL restored looks + /// "direct" — but it arrives on a link another peer already owns. That + /// link must not shortcut the (possibly absent) claimed peer into + /// "connected". + @Test + func directAnnounceOnLinkBoundToAnotherPeerDoesNotMarkConnected() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x88, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.linkBoundToOtherPeer = true + recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) + let handler = makeHandler(recorder: recorder, now: now) + + let result = handler.handle(packet, from: peerID) + + #expect(result?.isDirectAnnounce == true) + #expect(result?.isVerified == true) + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == false) + } + + /// A peer with its own live link stays connected even when a copy of its + /// announce arrives on someone else's link. + @Test + func directAnnounceOnForeignLinkKeepsPeerWithOwnLinkConnected() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x99, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.linkBoundToOtherPeer = true + recorder.linkState = (hasPeripheral: false, hasCentral: true) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == true) + } + @Test func announceBackIsSkippedWhenAlreadyMarked() throws { let now = Date(timeIntervalSince1970: 1_000) diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 78a9caec..b9a3df90 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -316,6 +316,76 @@ struct MessageRouterTests { #expect(couriers.contains(favorite)) } + /// Residual gap after the rotation-heal containment: a replayed "direct" + /// announce (TTL is unsigned) can bind an absent victim's peer ID to the + /// replayer's link, leaving the victim "connected" on a link whose Noise + /// handshake can never complete. The connected fast-path must not trust + /// that outright: the send still goes out (a genuine link finishes the + /// handshake), but a copy is retained and a sealed copy goes to couriers + /// so nothing is silently lost. + @Test @MainActor + func sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier() async { + let victim = PeerID(str: "00000000000000aa") + let victimKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + transport.connectedPeers.insert(victim) + transport.connectedPeers.insert(courier) + transport.securePeers = [] // no established Noise session with anyone + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: victim, recipientKey: victimKey) + ) + router.sendPrivate("Hello", to: victim, recipientNickname: "Peer", messageID: "cs1") + + // The send is still attempted (it kicks the handshake on a genuine + // link) but not trusted outright: a courier gets a sealed copy now … + #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs1"]) + #expect(transport.sentCourierMessages.count == 1) + #expect(transport.sentCourierMessages.first?.messageID == "cs1") + #expect(transport.sentCourierMessages.first?.recipientNoiseKey == victimKey) + #expect(transport.sentCourierMessages.first?.couriers == [courier]) + + // … and the retained copy keeps flushing until a delivery ack — a + // flush over the insecure link must resend without dropping it. + router.flushOutbox(for: victim) + router.flushOutbox(for: victim) + #expect(transport.sentPrivateMessages.count == 3) + router.markDelivered("cs1") + router.flushOutbox(for: victim) + #expect(transport.sentPrivateMessages.count == 3) + } + + /// With an established secure session the connected fast-path stays + /// exactly as before: trusted outright, no retained copy, no courier. + @Test @MainActor + func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async { + let peerID = PeerID(str: "00000000000000ab") + let peerKey = Data(repeating: 0xAB, count: 32) + let courier = PeerID(str: "00000000000000cc") + + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.connectedPeers.insert(courier) + transport.securePeers = [peerID] + transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: peerID, recipientKey: peerKey) + ) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "cs2") + + #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) + #expect(transport.sentCourierMessages.isEmpty) + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 1) + } + @Test @MainActor func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async { let recipient = PeerID(str: "00000000000000aa")