diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index e33248e3..306831ce 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,23 @@ 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. + // + // Known limitation: denying the shortcut cannot prevent forged + // presence outright. A rebind that passes the containment checks + // promotes the claimed peer to connected — it must, or a legitimate + // rotation on an open link would read as disconnected — so a replay + // that wins the rebind (absent victim, cooldown clear) still forges + // presence. That residue is presence display only: DMs stay gated on + // canDeliverSecurely (no Noise session means retain + courier, see + // MessageRouter.sendPrivate). What this check buys: the ambiguous + // announce alone never flips presence — forging requires winning the + // containment-checked rebind (never steals an identity that owns a + // live link; at most one rebind per link per cooldown window). + let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID) env.withRegistryBarrier { let hasPeripheralConnection = directLinkState.hasPeripheral @@ -152,7 +175,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/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 9195d147..792107d8 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -158,6 +158,16 @@ struct BLEPeerRegistry { peers[peerID] = info } + /// Flips an already-known peer to connected. Returns false when the peer + /// is unknown or already connected (nothing changed). + @discardableResult + mutating func markConnected(_ peerID: PeerID) -> Bool { + guard var info = peers[peerID], !info.isConnected else { return false } + info.isConnected = true + peers[peerID] = info + return true + } + mutating func updateLastSeen(_ peerID: PeerID, at date: Date) { guard var peer = peers[peerID] else { return } peer.lastSeen = date diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 9b18ed77..2152f316 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -674,6 +674,19 @@ 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. + // + // Sessions are keyed by the short wire ID, so normalize like + // isPeerConnected does — a send keyed by the full 64-hex Noise key + // must not misread an established session as insecure. + noiseService.hasEstablishedSession(with: peerID.toShort()) + } + func peerNickname(peerID: PeerID) -> String? { collectionsQueue.sync { peerRegistry.nickname(for: peerID, connectedOnly: true) @@ -2083,6 +2096,16 @@ extension BLEService { } } + /// Handshake plumbing for tests that need a real established Noise + /// session (e.g. canDeliverSecurely) without Bluetooth in the loop. + func _test_noiseInitiateHandshake(with peerID: PeerID) throws -> Data { + try noiseService.initiateHandshake(with: peerID) + } + + func _test_noiseProcessHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { + try noiseService.processHandshakeMessage(from: peerID, message: message) + } + static func _test_shouldRediscoverBitChatService( invalidatedServiceUUIDs: [CBUUID], cachedServiceUUIDs: [CBUUID]? @@ -4388,6 +4411,16 @@ extension BLEService { } SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) self.refreshLocalTopology() + // The announce that triggered this rebind was upserted as + // disconnected: the registry ran while the link still belonged + // to the previous ID (the ambiguous state BLEAnnounceHandler + // denies the connected shortcut). The rebind has now + // containment-checked the claim and the identity owns a live + // link, so promote it — otherwise a healed rotation leaves a + // live link that reads as disconnected until the next announce. + self.messageQueue.async { [weak self] in + self?.promoteReboundPeerToConnected(peerID) + } // Retire the rotated-away ID only once its last link is gone; a // remaining stale link heals the same way or ages out. guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return } @@ -4397,6 +4430,26 @@ extension BLEService { } } + /// After a successful verified rebind the new identity owns a live link, + /// but its announce was stored disconnected (the link was still bound to + /// the rotated-away ID when the registry upsert ran). Flip it to + /// connected and republish so routing and the peer list see the healed + /// link. The `.peerConnected` UI event already fired from the announce + /// path (new/reconnected + direct), so only list state needs refreshing. + private func promoteReboundPeerToConnected(_ peerID: PeerID) { + let promoted = collectionsQueue.sync(flags: .barrier) { + peerRegistry.markConnected(peerID) + } + guard promoted else { return } + refreshLocalTopology() + publishFullPeerData() + notifyUI { [weak self] in + guard let self else { return } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + } + } + /// Rotation is an implicit leave of the old identity: drop it immediately /// instead of letting a ghost duplicate linger for the reachability /// retention window. @@ -4434,6 +4487,25 @@ extension BLEService { linkState: { [weak self] peerID in self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) }, + linkBoundToOtherPeer: { [weak self] packet, peerID in + // Reads the CURRENT binding — i.e. the state before + // rebindLinkAfterVerifiedDirectAnnounce (which runs after the + // handler) may steal the link and promote the new owner to + // connected. See the caller in BLEAnnounceHandler for why the + // residual forged-presence window this leaves is accepted. + 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 bfbba094..819c30c7 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -165,14 +165,38 @@ 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. + // + // Deliberate metadata tradeoff: every pre-handshake first DM to a + // connected peer hands nearby verified peers a sealed copy, so + // they learn a DM to this recipient exists (never its content — + // the envelope is opaque). Accepted for delivery robustness; the + // deposit is cleared on ack. Don't "optimize" the courier call + // away. + 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. @@ -397,14 +421,31 @@ 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 = connectedTransport(for: peerID) { + // "Connected" without a secure session — possibly a stolen + // binding from a replayed announce: send (a genuine link + // finishes the handshake and delivers) but keep retaining + // until an ack clears it. These flushes do NOT count toward + // the attempt-cap drop: the message was transmitted over a + // live link, so a peer whose handshake stalls across + // reconnect flapping must not burn through the cap and lose + // the store-and-forward copy this retention exists to + // preserve. Retention stays bounded by the 24h outbox TTL + // and the per-peer FIFO cap. + SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) 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) + remaining.append(message) } 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. + // Reachability without a connection is a freshness heuristic, + // so the send can silently go nowhere: 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/Prekeys/PrekeyBundleStore.swift b/bitchat/Services/Prekeys/PrekeyBundleStore.swift index 18d6ea00..83527ef9 100644 --- a/bitchat/Services/Prekeys/PrekeyBundleStore.swift +++ b/bitchat/Services/Prekeys/PrekeyBundleStore.swift @@ -23,10 +23,11 @@ import Foundation final class PrekeyBundleStore { struct StoredBundle: Codable { // noiseKey is read in loadFromDisk (dictionary keying), but the - // Periphery indexer intermittently misses that read and flakes CI - // with "assign-only" — its USR is baselined (an in-source ignore - // can't work: strict mode flags it as superfluous on the runs where - // the indexer gets it right). + // Periphery indexer intermittently misses that read and flaked CI + // with "assign-only" — even past its baselined USR. Covered + // deterministically by retain_codable_properties in .periphery.yml + // (an in-source ignore can't work: strict mode flags it as + // superfluous on the runs where the indexer gets it right). let noiseKey: Data var generatedAt: UInt64 var prekeyIDs: [UInt32] diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 35a794cb..c7094bc1 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] @@ -247,6 +255,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..c7065f6d 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -253,6 +253,144 @@ struct BLEServiceCoreTests { #expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID)) } + @Test + func replayedDirectAnnounceForAbsentPeerNeverYieldsSecureDelivery() 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 steals the link binding, and because a successful rebind + // promotes its new owner to connected (a legitimate rotation heal + // requires that), the absent victim may read as connected. That + // forged presence is display-only and accepted — the invariant that + // holds is that the stolen link can never produce an established + // Noise session, so MessageRouter's canDeliverSecurely gate routes + // DMs through retain + courier instead of trusting it outright. + 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 rebind steals the link (no live link owns the victim's + // identity, so containment cannot refuse) … + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(attackerLink) == victimPeerID }, + timeout: TestConstants.longTimeout + ) + #expect(rebound) + // … and the promote marks the absent victim connected: the accepted, + // display-only forged-presence residue (documented at + // BLEAnnounceHandler's linkBoundToOtherPeer check) … + let forgedPresence = await TestHelpers.waitUntil( + { ble.isPeerConnected(victimPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(forgedPresence) + // … but secure delivery stays impossible — the DM gate holds, and + // MessageRouter retains + couriers instead of trusting the link. + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + } + + /// A legitimate rotation announce necessarily arrives on a link still + /// bound to the OLD ID, so its registry upsert stores the new peer + /// disconnected. The successful rebind must promote it: a healed + /// rotation with a live link has to read as connected again for routing + /// and outbox flushes. + @Test + func rotationHealPromotesRotatedPeerToConnected() async throws { + let ble = makeService() + let oldPeerID = PeerID(str: "1122334455667788") + let centralUUID = "central-rotation-promote" + + ble._test_seedConnectedPeer(oldPeerID, nickname: "alice") + ble._test_bindCentral(centralUUID, to: oldPeerID) + + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let announcement = AnnouncementPacket( + nickname: "alice", + noisePublicKey: signer.getStaticPublicKeyData(), + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode(), "Failed to encode announcement") + let newPeerID = PeerID(publicKey: announcement.noisePublicKey) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: newPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet") + + #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID)) + ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false) + + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(centralUUID) == newPeerID }, + timeout: TestConstants.longTimeout + ) + #expect(rebound) + + let connected = await TestHelpers.waitUntil( + { ble.isPeerConnected(newPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(connected) + } + + /// Noise sessions are keyed by the short wire ID, but routers may key + /// sends by the full 64-hex Noise key (favorites resolution does). The + /// secure-delivery gate must normalize like isPeerConnected, or an + /// established session is misread as insecure and every DM needlessly + /// retains + couriers until an ack. + @Test + func canDeliverSecurelyNormalizesFullNoiseKeyPeerIDs() async throws { + let ble = makeService() + let remote = NoiseEncryptionService(keychain: MockKeychain()) + let remoteKey = remote.getStaticPublicKeyData() + let shortID = PeerID(publicKey: remoteKey) + let fullKeyID = PeerID(hexData: remoteKey) + #expect(fullKeyID.toShort() == shortID) + #expect(!ble.canDeliverSecurely(to: shortID)) + + // Full XX handshake; the local side keys the session by the short + // wire ID, exactly as packets present it in production. + let m1 = try ble._test_noiseInitiateHandshake(with: shortID) + let m2 = try #require(try remote.processHandshakeMessage(from: ble.myPeerID, message: m1)) + let m3 = try #require(try ble._test_noiseProcessHandshakeMessage(from: shortID, message: m2)) + _ = try remote.processHandshakeMessage(from: ble.myPeerID, message: m3) + + #expect(ble.canDeliverSecurely(to: shortID)) + #expect(ble.canDeliverSecurely(to: fullKeyID)) + } + @Test func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws { let ble = makeService() diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 62ced6c9..ad01a1c4 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -51,6 +51,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] = [:] @@ -88,6 +92,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..9623573a 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,105 @@ 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) + } + + /// Documents the handler's contract around the rebind: the + /// linkBoundToOtherPeer read reflects the binding BEFORE the rebind runs, + /// so a replayed "direct" announce on someone else's link is denied the + /// connected shortcut — presence only flips via the rebind itself + /// (BLEService promotes the new owner after a successful, containment- + /// checked rebind) or via a later announce arriving on the now-rebound + /// link, as simulated here. Either way the residue is presence display + /// only — DMs remain gated on canDeliverSecurely, so without a Noise + /// session they take the retain + courier path (see MessageRouterTests + /// .sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier). + @Test + func secondReplayedDirectAnnounceAfterRebindMarksAbsentPeerConnected() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0xA1, count: 32) + let victim = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: victim, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + + // First replay: the link still belongs to the replayer and the victim + // has no live link of its own — the connected shortcut is denied. + recorder.linkBoundToOtherPeer = true + recorder.linkState = (hasPeripheral: false, hasCentral: false) + handler.handle(packet, from: victim) + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == false) + + // The rebind then binds the replayer's link to the victim's ID (the + // rotation-heal path, containment-checked in BLEService). A second + // replay now finds the link owned by the claimed peer … + recorder.linkBoundToOtherPeer = false + recorder.linkState = (hasPeripheral: false, hasCentral: true) + handler.handle(packet, from: victim) + + // … and the absent victim reads as connected: the residual gap. + #expect(recorder.upsertCalls.count == 2) + #expect(recorder.upsertCalls.last?.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..ea8ba4da 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -316,6 +316,108 @@ 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) + } + + /// Flushes over a connected-but-insecure link never count toward the + /// attempt-cap drop: the message was actually transmitted over a live + /// link, so a peer whose Noise handshake stalls across reconnect flapping + /// must not burn through the cap and lose the store-and-forward copy the + /// secure-session gate exists to preserve. Retention stays bounded by + /// the 24h outbox TTL and the per-peer FIFO cap; an ack clears it. + @Test @MainActor + func flushOutbox_connectedInsecureFlushesNeverDropTheRetainedCopy() async { + let peerID = PeerID(str: "00000000000000ac") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [] // handshake never completes + + let router = MessageRouter(transports: [transport]) + var dropped: [String] = [] + router.onMessageDropped = { messageID, _ in dropped.append(messageID) } + + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "ci1") + + // Well past maxSendAttempts (8): every flush resends, none drops. + for _ in 0..<10 { + router.flushOutbox(for: peerID) + } + #expect(dropped.isEmpty) + #expect(transport.sentPrivateMessages.count == 11) + + // The copy is still retained and an ack still clears it. + router.markDelivered("ci1") + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 11) + } + + /// 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")