mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
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>
247 lines
11 KiB
Swift
247 lines
11 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|