Wi-Fi bulk: normalize peer ID before eligibility checks

Stable/verified private chats address a peer by its full 64-hex Noise
key, but the Noise session, advertised capabilities, and connection state
are keyed by the short (SHA256-derived 16-hex) routing ID. sendFilePrivate
resolved the Wi-Fi bulk SendCandidate with the raw peerID, so a 64-hex key
made hasEstablishedSession return false; shouldOffer never selected Wi-Fi
and a >BLE-cap payload cancelled as "Wi-Fi unavailable" even when the
direct peer advertised .wifiBulk.

Normalize with peerID.toShort() once before the session/capability checks
(extracted into wifiBulkSendCandidate) and use the normalized ID for the
negotiation packet and the BLE fallback too.

Test: eligibility + offer path resolves when addressed by a 64-hex Noise
key — the session is established under the short ID, the raw-key lookup
misses it, and the normalized send path still offers Wi-Fi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 21:21:37 +02:00
co-authored by Claude Fable 5
parent e4b3cff5fa
commit 2b7fd2002b
2 changed files with 105 additions and 9 deletions
+60 -9
View File
@@ -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
+45
View File
@@ -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 {