diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 6201ce8c..ff5d6ff4 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -793,15 +793,19 @@ final class BLEService: NSObject { } let fitsBLECaps = filePacket.encode() != nil - let candidate = WifiBulkPolicy.SendCandidate( - payloadBytes: payload.count, - peerCapabilities: self.peerCapabilities(peerID), - isDirectlyConnected: self.isPeerConnected(peerID), - hasEstablishedNoiseSession: self.noiseService.hasEstablishedSession(with: peerID) - ) + // Normalize to the short routing ID (SHA256-derived 16-hex) once. + // Stable/verified private chats address the peer by its full + // 64-hex Noise key, but the Noise session and the negotiation + // packet are keyed by the short ID — so the capability/session + // eligibility checks (and the Wi-Fi offer) must all use it, or a + // 64-hex key makes `hasEstablishedSession` return false and Wi-Fi + // is never selected even when the peer advertises `.wifiBulk`. + let routingID = peerID.toShort() + + let candidate = self.wifiBulkSendCandidate(payloadBytes: payload.count, to: routingID) if WifiBulkPolicy.shouldOffer(candidate) { - self.wifiBulkService.sendFile(payload: payload, to: peerID, transferId: transferId) { [weak self] in - self?.sendFilePrivateViaBLE(payload: payload, to: peerID, transferId: transferId, fitsBLECaps: fitsBLECaps) + self.wifiBulkService.sendFile(payload: payload, to: routingID, transferId: transferId) { [weak self] in + self?.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: fitsBLECaps) } return } @@ -811,10 +815,29 @@ final class BLEService: NSObject { self.surfaceUndeliverableTransfer(transferId) return } - self.sendFilePrivateViaBLE(payload: payload, to: peerID, transferId: transferId, fitsBLECaps: true) + self.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: true) } } + /// Builds the Wi-Fi bulk eligibility candidate for a private send. + /// + /// Normalizes `peerID` to its short routing ID first: stable/verified + /// private chats address the peer by its full 64-hex Noise key, but the + /// Noise session, advertised capabilities, and connection state are all + /// keyed by the short (SHA256-derived 16-hex) ID. Resolving them with the + /// 64-hex key would miss the session (`hasEstablishedSession` returns + /// false), so Wi-Fi would never be offered even to a directly-connected + /// `.wifiBulk` peer. + private func wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate { + let routingID = peerID.toShort() + return WifiBulkPolicy.SendCandidate( + payloadBytes: payloadBytes, + peerCapabilities: peerCapabilities(routingID), + isDirectlyConnected: isPeerConnected(routingID), + hasEstablishedNoiseSession: noiseService.hasEstablishedSession(with: routingID) + ) + } + /// BLE fragmentation path for private file transfers; also the fallback /// target when a Wi-Fi bulk attempt declines, times out, or errors. /// May be invoked from the Wi-Fi bulk queue. @@ -1833,6 +1856,34 @@ extension BLEService { cachedServiceUUIDs: cachedServiceUUIDs ) } + + /// The internal Noise service, so tests can drive a real handshake and + /// establish a session keyed by a chosen (short) routing ID. + var _test_noiseService: NoiseEncryptionService { noiseService } + + /// Registers a directly-connected peer with the given advertised + /// capabilities, keyed by its short routing ID (as production does). + func _test_registerConnectedPeer(_ peerID: PeerID, capabilities: PeerCapabilities) { + let shortID = peerID.toShort() + collectionsQueue.sync(flags: .barrier) { + peerRegistry.upsert(BLEPeerInfo( + peerID: shortID, + nickname: "TestPeer_\(shortID.id.prefix(4))", + isConnected: true, + noisePublicKey: peerID.noiseKey, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: Date(), + capabilities: capabilities + )) + } + } + + /// Runs the production Wi-Fi bulk eligibility resolution (including peer-ID + /// normalization) for the given payload size and recipient. + func _test_wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate { + wifiBulkSendCandidate(payloadBytes: payloadBytes, to: peerID) + } } #endif diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index fe7624ec..19ddfb42 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -216,6 +216,51 @@ struct BLEServiceCoreTests { cachedServiceUUIDs: [BLEService.serviceUUID, otherService] )) } + + // Regression: a stable/verified private chat addresses the peer by its + // full 64-hex Noise key, but the Noise session (and capabilities/ + // connection) are keyed by the short routing ID. The Wi-Fi bulk send path + // must normalize the ID first, or `hasEstablishedSession` misses and + // Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer. + @Test + func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws { + let ble = makeService() + + let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) }) + let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key + #expect(fullKey.noiseKey != nil) + let shortID = fullKey.toShort() // 16-hex routing ID + #expect(shortID != fullKey) + + // Establish a real Noise session in the service's noise engine, keyed + // by the short routing ID (exactly as a completed handshake would). + let peer = NoiseEncryptionService(keychain: MockKeychain()) + let noise = ble._test_noiseService + let m1 = try noise.initiateHandshake(with: shortID) + let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1)) + let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2)) + _ = try peer.processHandshakeMessage(from: shortID, message: m3) + + #expect(noise.hasEstablishedSession(with: shortID)) + // The bug in raw form: querying by the 64-hex key misses the session. + #expect(!noise.hasEstablishedSession(with: fullKey)) + + // Register the peer as a directly-connected `.wifiBulk` neighbor. + ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk]) + + let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes + + // The send path normalizes first, so eligibility + offer resolve for + // both the short ID and the full 64-hex Noise key. + let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID) + let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey) + #expect(viaShort.hasEstablishedNoiseSession) + #expect(viaFull.hasEstablishedNoiseSession) + #expect(viaFull.isDirectlyConnected) + #expect(viaFull.peerCapabilities.contains(.wifiBulk)) + #expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true)) + #expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true)) + } } private func makeService() -> BLEService {