mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:05:19 +00:00
Wi-Fi bulk transport: AWDL data plane for large media with BLE fallback
BLE stays the control plane: a sender with a queued file > 64 KiB to a directly connected peer advertising the wifiBulk capability sends a bulkTransferOffer (0x04) inside the established Noise session — transferID, file size, payload SHA-256, a fresh 32-byte token, and a random per-transfer Bonjour instance name. The receiver answers bulkTransferResponse (0x05) with its own token half, then both sides meet on a per-transfer _bitchat-bulk._tcp channel over peer-to-peer Wi-Fi (AWDL). The TCP stream is secured independently of TLS: both tokens traveled inside Noise, so only the two peers can derive the ChaChaPoly channel key (HKDF-SHA256, domain "bitchat-bulk-v1", transferID as salt). Frames are length-prefixed sealed boxes with structured direction+counter nonces; the first frame must prove knowledge of the key or the client is disconnected, and the final hash is verified against the offer before delivery. Decline, timeout, or any mid-transfer error falls back to BLE fragmentation exactly once, driving the same TransferProgressManager stream so the UI is unchanged. Wi-Fi-negotiated transfers may carry up to 8 MiB (new FileTransferLimits.maxWifiBulkPayloadBytes, enforced by the receiver from the accepted offer); the BLE path keeps its existing caps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// WifiBulkLoopbackTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// End-to-end integration over real Network.framework sockets on localhost:
|
||||
/// two `WifiBulkTransferService` instances negotiate through an in-process
|
||||
/// "Noise" pipe, then move the payload over a genuine TCP connection using
|
||||
/// the production listener/browser-free test hooks (peer-to-peer and Bonjour
|
||||
/// are disabled — unit-test hosts have no AWDL or mDNS access).
|
||||
@Suite("WifiBulk loopback integration", .serialized)
|
||||
struct WifiBulkLoopbackTests {
|
||||
private static let senderPeer = PeerID(str: "00112233aabbccdd")
|
||||
private static let receiverPeer = PeerID(str: "ddccbbaa33221100")
|
||||
|
||||
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||
var config = WifiBulkTransferServiceConfig()
|
||||
config.usePeerToPeer = false
|
||||
config.publishBonjourService = false
|
||||
config.offerTimeout = 5.0
|
||||
config.transferWindow = 10.0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test("Payload crosses a real TCP loopback channel and lands verified")
|
||||
func loopbackTransferSucceeds() async throws {
|
||||
let payload = Data((0..<300_000).map { UInt8($0 % 249) })
|
||||
|
||||
let delivered = WifiBulkTestBox<Data>()
|
||||
let progress = WifiBulkTestBox<String>()
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||
let offers = WifiBulkTestBox<Data>()
|
||||
|
||||
var senderService: WifiBulkTransferService?
|
||||
var receiverService: WifiBulkTransferService?
|
||||
|
||||
// Once the receiver has accepted AND the listener port is known,
|
||||
// connect the receiver straight to 127.0.0.1 (Bonjour stand-in).
|
||||
let accepted = WifiBulkTestBox<Data>()
|
||||
let connectIfReady: () -> Void = {
|
||||
guard let (transferID, port) = ports.values.first,
|
||||
accepted.values.contains(transferID),
|
||||
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
receiverService?._test_connectIncoming(
|
||||
transferID: transferID,
|
||||
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||
)
|
||||
}
|
||||
|
||||
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
// Sender → receiver control plane (offer).
|
||||
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||
offers.append(Data(typed.dropFirst()))
|
||||
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { _, _, _ in },
|
||||
progressStart: { id, total in progress.append("start:\(id):\(total)") },
|
||||
progressChunkSent: { id in progress.append("chunk:\(id)") },
|
||||
progressReset: { id in progress.append("reset:\(id)") },
|
||||
progressCancel: { id in progress.append("cancel:\(id)") }
|
||||
)
|
||||
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
// Receiver → sender control plane (response).
|
||||
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||
let body = Data(typed.dropFirst())
|
||||
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||
accepted.append(response.transferID)
|
||||
}
|
||||
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||
connectIfReady()
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { data, peer, limit in
|
||||
#expect(peer == Self.senderPeer)
|
||||
#expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes)
|
||||
delivered.append(data)
|
||||
},
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
|
||||
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||
sender._test_onListenerReady = { transferID, port in
|
||||
ports.append((transferID, port))
|
||||
connectIfReady()
|
||||
}
|
||||
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||
senderService = sender
|
||||
receiverService = receiver
|
||||
|
||||
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
|
||||
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||
#expect(delivered.values.first == payload)
|
||||
#expect(fallbacks.count == 0)
|
||||
|
||||
// Deterministic teardown: both sides drop all transfer state.
|
||||
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||
#expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 })
|
||||
|
||||
// Progress mirrored the BLE contract: start with the chunk total,
|
||||
// then exactly `total` chunk ticks (the last one gated on the receipt).
|
||||
let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes
|
||||
#expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks })
|
||||
#expect(progress.values.first == "start:t-loopback:\(totalChunks)")
|
||||
#expect(!progress.values.contains("reset:t-loopback"))
|
||||
}
|
||||
|
||||
@Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds")
|
||||
func gatecrasherIsRejected() async throws {
|
||||
let payload = Data((0..<150_000).map { UInt8($0 % 241) })
|
||||
|
||||
let delivered = WifiBulkTestBox<Data>()
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||
let accepted = WifiBulkTestBox<Data>()
|
||||
|
||||
var senderService: WifiBulkTransferService?
|
||||
var receiverService: WifiBulkTransferService?
|
||||
|
||||
let gatecrashed = WifiBulkTestBox<Bool>()
|
||||
let connectIfReady: () -> Void = {
|
||||
guard let (transferID, port) = ports.values.first,
|
||||
accepted.values.contains(transferID),
|
||||
gatecrashed.count > 0,
|
||||
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
receiverService?._test_connectIncoming(
|
||||
transferID: transferID,
|
||||
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||
)
|
||||
}
|
||||
|
||||
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { _, _, _ in },
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||
let body = Data(typed.dropFirst())
|
||||
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||
accepted.append(response.transferID)
|
||||
}
|
||||
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||
connectIfReady()
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { data, _, _ in delivered.append(data) },
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
|
||||
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||
let gateQueue = DispatchQueue(label: "test.gatecrasher")
|
||||
sender._test_onListenerReady = { transferID, port in
|
||||
ports.append((transferID, port))
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
// A stranger who saw the Bonjour advertisement connects first and
|
||||
// sends garbage that cannot carry a valid MAC.
|
||||
let crasher = NWConnection(
|
||||
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort),
|
||||
using: .tcp
|
||||
)
|
||||
crasher.stateUpdateHandler = { state in
|
||||
if case .ready = state {
|
||||
let junkBody = Data(repeating: 0xAA, count: 60)
|
||||
crasher.send(
|
||||
content: WifiBulkCrypto.frameData(body: junkBody),
|
||||
completion: .contentProcessed { _ in
|
||||
gatecrashed.append(true)
|
||||
connectIfReady()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
crasher.start(queue: gateQueue)
|
||||
}
|
||||
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||
senderService = sender
|
||||
receiverService = receiver
|
||||
|
||||
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
|
||||
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||
#expect(delivered.values.first == payload)
|
||||
#expect(fallbacks.count == 0)
|
||||
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||
}
|
||||
|
||||
@Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE")
|
||||
func vanishedReceiverFallsBack() async {
|
||||
var config = makeConfig()
|
||||
config.offerTimeout = 0.3
|
||||
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let environment = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { _, _, _ in },
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
let sender = WifiBulkTransferService(environment: environment, config: config)
|
||||
sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||
#expect(sender._test_activeOutgoingCount == 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user