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:
jack
2026-07-06 20:35:40 +02:00
co-authored by Claude Fable 5
parent 688b954fb8
commit e4b3cff5fa
21 changed files with 2614 additions and 21 deletions
+6
View File
@@ -33,12 +33,18 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBonjourServices</key>
<array>
<string>_bitchat-bulk._tcp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
+11 -7
View File
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
func encode() -> Data? {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard resolvedSize <= UInt64(limit) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
static func decode(_ data: Data) -> BitchatFilePacket? {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
var cursor = data.startIndex
let end = data.endIndex
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
if size > UInt64(limit) {
return nil
}
fileSize = size
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > FileTransferLimits.maxPayloadBytes {
if proposedSize > limit {
return nil
}
content.append(contentsOf: value)
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
+5
View File
@@ -72,6 +72,9 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
@@ -81,6 +84,8 @@ enum NoisePayloadType: UInt8 {
case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt"
case .delivered: return "delivered"
case .bulkTransferOffer: return "bulkTransferOffer"
case .bulkTransferResponse: return "bulkTransferResponse"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
}
@@ -3,5 +3,5 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = []
static let localSupported: PeerCapabilities = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
}
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
/// pass the ceiling that was enforced against the accepted offer.
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
}
enum BLEIncomingFileValidator {
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload) else {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
/// pass the ceiling enforced against the accepted offer.
static func validate(
payload: Data,
limit: Int = FileTransferLimits.maxPayloadBytes
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
return .failure(.malformedPayload)
}
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
}
+126 -2
View File
@@ -145,6 +145,8 @@ final class BLEService: NSObject {
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
// File-transfer orchestration (queue hops stay in the environment closures)
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
// Wi-Fi bulk data plane: BLE/Noise negotiates, AWDL carries large media
private lazy var wifiBulkService = WifiBulkTransferService(environment: makeWifiBulkEnvironment())
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
@@ -269,6 +271,12 @@ final class BLEService: NSObject {
// Initialize gossip sync manager
restartGossipManager()
// Force single-threaded instantiation: unlike the packet handlers
// (touched only from messageQueue), the Wi-Fi bulk service is reached
// from the main actor too (cancelTransfer/stopServices), and lazy
// vars are not thread-safe.
_ = wifiBulkService
}
private func restartGossipManager() {
@@ -504,6 +512,9 @@ final class BLEService: NSObject {
}
func stopServices() {
// Tear down any Wi-Fi bulk listeners/connections deterministically
wifiBulkService.stop()
// Send leave message synchronously to ensure delivery
var leavePacket = BitchatPacket(
type: MessageType.leave.rawValue,
@@ -696,6 +707,9 @@ final class BLEService: NSObject {
// MARK: Messaging
func cancelTransfer(_ transferId: String) {
// A transfer may be riding the Wi-Fi bulk channel instead of the BLE
// fragment scheduler; cancelling both is safe (each no-ops on miss).
wifiBulkService.cancelTransfer(transferId: transferId)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
@@ -771,10 +785,49 @@ final class BLEService: NSObject {
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard let payload = filePacket.encode() else {
// Encode with the Wi-Fi bulk ceiling; whether the payload also
// fits the BLE caps decides what a fallback may do.
guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
return
}
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)
)
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)
}
return
}
guard fitsBLECaps else {
// Wi-Fi-only payload with no eligible Wi-Fi path.
SecureLogger.error("❌ File exceeds BLE caps and Wi-Fi bulk is unavailable", category: .session)
self.surfaceUndeliverableTransfer(transferId)
return
}
self.sendFilePrivateViaBLE(payload: payload, to: peerID, transferId: transferId, fitsBLECaps: true)
}
}
/// 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.
private func sendFilePrivateViaBLE(payload: Data, to peerID: PeerID, transferId: String, fitsBLECaps: Bool) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard fitsBLECaps else {
// A >1 MiB payload was negotiated for Wi-Fi and the channel
// failed: BLE cannot carry it, surface the normal failure path.
SecureLogger.error("❌ Wi-Fi bulk failed and payload exceeds BLE caps (\(payload.count) bytes)", category: .session)
self.surfaceUndeliverableTransfer(transferId)
return
}
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
// This ensures 64-hex Noise keys are converted to the canonical routing format
let targetID = peerID.toShort()
@@ -803,6 +856,13 @@ final class BLEService: NSObject {
}
}
/// Drives the progress bus through startedcancelled so the UI clears a
/// transfer that no transport can carry.
private func surfaceUndeliverableTransfer(_ transferId: String) {
TransferProgressManager.shared.start(id: transferId, totalFragments: 1)
TransferProgressManager.shared.cancel(id: transferId)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
@@ -1188,6 +1248,57 @@ final class BLEService: NSObject {
)
}
/// Builds the Wi-Fi bulk service environment. Noise encryption and packet
/// dispatch stay on the message queue; incoming payloads re-enter the
/// normal file-transfer pipeline as if a fully assembled packet arrived.
private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment {
WifiBulkTransferServiceEnvironment(
sendNoisePayload: { [weak self] typedPayload, peerID in
guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false }
self.messageQueue.async { [weak self] in
guard let self = self else { return }
do {
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
} catch {
SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session)
}
}
return true
},
isPeerConnected: { [weak self] peerID in
self?.isPeerConnected(peerID) ?? false
},
deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in
self?.messageQueue.async { [weak self] in
guard let self = self else { return }
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: peerID.toShort().id) ?? Data(),
recipientID: self.myPeerIDData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0,
version: 2
)
self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit)
}
},
progressStart: { transferId, totalChunks in
TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks)
},
progressChunkSent: { transferId in
TransferProgressManager.shared.recordFragmentSent(id: transferId)
},
progressReset: { transferId in
TransferProgressManager.shared.reset(id: transferId)
},
progressCancel: { transferId in
TransferProgressManager.shared.cancel(id: transferId)
}
)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
@@ -3510,8 +3621,21 @@ extension BLEService {
self?.noiseService.clearSession(for: peerID)
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
guard let self = self else { return }
// Wi-Fi bulk negotiation is a transport concern; consume it
// here instead of surfacing it to the UI layer.
switch type {
case .bulkTransferOffer:
self.wifiBulkService.handleOfferPayload(payload, from: peerID)
return
case .bulkTransferResponse:
self.wifiBulkService.handleResponsePayload(payload, from: peerID)
return
default:
break
}
// Single main-actor hop delivering `.noisePayloadReceived`.
self?.notifyUI { [weak self] in
self.notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: type,
+15
View File
@@ -281,6 +281,21 @@ enum TransportConfig {
static let syncResponseRateLimitMaxResponses: Int = 8
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
// BLE stays the control plane: offers/responses ride the Noise session,
// only the sealed chunk stream moves to TCP over AWDL.
static let wifiBulkEnabled: Bool = true
// Below this size BLE fragmentation is fast enough that negotiation
// overhead isn't worth it.
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
static let wifiBulkChunkBytes: Int = 64 * 1024
// Offer unanswered for this long fall back to BLE fragmentation.
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
// Hard ceiling on how long the Bonjour listener/connection may live.
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
static let wifiBulkMaxConcurrentIncoming: Int = 4
// Courier store-and-forward
// Initial spray-and-wait budget per deposited envelope: each courier may
// hand half its remaining copies to another courier on encounter, so a
@@ -0,0 +1,463 @@
//
// WifiBulkChannel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
import Network
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
/// the connection's dispatch queue.
enum WifiBulkStream {
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
chunkBytes + WifiBulkCrypto.frameOverhead
}
/// Reads frames until `onFrame` returns false (stop) or the stream
/// errors/closes. `onFrame` returning true keeps the loop alive.
static func readFrames(
on connection: NWConnection,
buffer: WifiBulkFrameBuffer,
maxFrameBodyBytes: Int,
onFrame: @escaping (Data) -> Bool,
onError: @escaping (String) -> Void
) {
// Drain any frames already buffered before touching the socket.
do {
while let body = try buffer.nextFrameBody() {
guard onFrame(body) else { return }
}
} catch {
onError("frame decode failed: \(error)")
return
}
connection.receive(
minimumIncompleteLength: 1,
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
) { data, _, isComplete, error in
if let data, !data.isEmpty {
buffer.append(data)
}
if let error {
onError("receive failed: \(error)")
return
}
if isComplete {
// Peer closed: hand over whatever complete frames remain, then
// report the close (sessions that already got what they need
// will have stopped the loop from inside onFrame).
do {
while let body = try buffer.nextFrameBody() {
guard onFrame(body) else { return }
}
} catch {
onError("frame decode failed: \(error)")
return
}
onError("connection closed by peer")
return
}
readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: maxFrameBodyBytes,
onFrame: onFrame,
onError: onError
)
}
}
}
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
/// listener, requires the first inbound frame to prove knowledge of the
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
/// receiver's verified receipt.
///
/// The listener starts at offer time (Bonjour registration takes a moment)
/// but data can only flow after `activate(key:)` supplies the channel key
/// derived from the accepted response.
final class WifiBulkSenderSession {
private let queue: DispatchQueue
private let payload: Data
private let transferID: Data
private let payloadHash: Data
private let chunkBytes: Int
private let parameters: NWParameters
private let service: NWListener.Service?
private let maxCandidateConnections = 4
private var key: SymmetricKey?
private var listener: NWListener?
/// Connections that have not yet produced a valid auth frame.
private var candidates: [NWConnection] = []
private var authenticated: NWConnection?
private var finished = false
let totalChunks: Int
/// Test hook: fires once the listener is ready, with its bound port.
var onListenerReady: ((UInt16) -> Void)?
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
var onCompleted: (() -> Void)?
var onFailed: ((String) -> Void)?
init(
payload: Data,
transferID: Data,
chunkBytes: Int,
parameters: NWParameters,
service: NWListener.Service?,
queue: DispatchQueue
) {
self.payload = payload
self.transferID = transferID
self.payloadHash = Data(SHA256.hash(data: payload))
self.chunkBytes = chunkBytes
self.parameters = parameters
self.service = service
self.queue = queue
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
}
deinit {
cancelNetworkResources()
}
/// Starts the listener. Returns false when the listener cannot be created
/// (caller falls back to BLE immediately).
func start() -> Bool {
let listener: NWListener
do {
listener = try NWListener(using: parameters)
} catch {
SecureLogger.error("WifiBulk: listener creation failed: \(error)", category: .session)
return false
}
listener.service = service
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .ready:
if let port = listener.port?.rawValue {
self.onListenerReady?(port)
}
case .failed(let error):
self.fail("listener failed: \(error)")
default:
break
}
}
listener.newConnectionHandler = { [weak self] connection in
self?.acceptCandidate(connection)
}
self.listener = listener
listener.start(queue: queue)
return true
}
/// Supplies the channel key once the receiver accepted the offer; begins
/// authenticating any connections that raced ahead of the response.
func activate(key: SymmetricKey) {
guard !finished, self.key == nil else { return }
self.key = key
for candidate in candidates {
beginAuthentication(on: candidate, key: key)
}
}
func cancel() {
finished = true
cancelNetworkResources()
}
// MARK: - Connection handling
private func acceptCandidate(_ connection: NWConnection) {
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
connection.cancel()
return
}
candidates.append(connection)
connection.stateUpdateHandler = { [weak self, weak connection] state in
guard let self, let connection else { return }
if case .failed = state {
self.dropCandidate(connection)
}
}
connection.start(queue: queue)
if let key {
beginAuthentication(on: connection, key: key)
}
}
private func dropCandidate(_ connection: NWConnection) {
if let index = candidates.firstIndex(where: { $0 === connection }) {
candidates.remove(at: index)
connection.cancel()
}
}
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
WifiBulkStream.readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
onFrame: { [weak self, weak connection] body in
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
// Bonjour-level gatecrasher: no channel key, no service.
SecureLogger.warning("WifiBulk: disconnecting client with invalid auth frame", category: .security)
self.dropCandidate(connection)
return false
}
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
return false
},
onError: { [weak self, weak connection] _ in
guard let self, let connection, self.authenticated !== connection else { return }
self.dropCandidate(connection)
}
)
}
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
authenticated = connection
// One authenticated peer is all a transfer needs: stop advertising and
// shed the other candidates.
listener?.cancel()
listener = nil
for candidate in candidates where candidate !== connection {
candidate.cancel()
}
candidates.removeAll()
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
}
// MARK: - Streaming
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
guard !finished else { return }
guard index < totalChunks else {
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
return
}
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
let chunk = Data(payload[start..<end])
let body: Data
do {
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
} catch {
fail("chunk seal failed: \(error)")
return
}
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
guard let self, !self.finished else { return }
if let error {
self.fail("send failed: \(error)")
return
}
self.onChunkSent?(index + 1, self.totalChunks)
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
})
}
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
WifiBulkStream.readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
onFrame: { [weak self] body in
guard let self, !self.finished else { return false }
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
self.fail("invalid receipt frame")
return false
}
self.finished = true
self.cancelNetworkResources()
self.onCompleted?()
return false
},
onError: { [weak self] reason in
self?.fail("receipt wait failed: \(reason)")
}
)
}
// MARK: - Teardown
private func fail(_ reason: String) {
guard !finished else { return }
finished = true
cancelNetworkResources()
onFailed?(reason)
}
private func cancelNetworkResources() {
listener?.cancel()
listener = nil
authenticated?.cancel()
authenticated = nil
for candidate in candidates {
candidate.cancel()
}
candidates.removeAll()
}
}
/// Receiver side of the bulk channel: connects to the sender's per-transfer
/// endpoint, proves knowledge of the channel key with the first frame, then
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
final class WifiBulkReceiverSession {
private let queue: DispatchQueue
private let connection: NWConnection
private let key: SymmetricKey
private let transferID: Data
private let payloadHash: Data
private let chunkBytes: Int
private let assembler: WifiBulkPayloadAssembler
private var finished = false
var onCompleted: ((Data) -> Void)?
var onFailed: ((String) -> Void)?
/// Fails (returns nil) when the offer exceeds `sizeCap` the receiver
/// enforces the cap it advertised, not the sender's word.
init?(
endpoint: NWEndpoint,
parameters: NWParameters,
key: SymmetricKey,
transferID: Data,
expectedSize: UInt64,
expectedHash: Data,
sizeCap: Int,
chunkBytes: Int,
queue: DispatchQueue
) {
guard let assembler = WifiBulkPayloadAssembler(
key: key,
expectedSize: expectedSize,
expectedHash: expectedHash,
sizeCap: sizeCap
) else {
return nil
}
self.assembler = assembler
self.connection = NWConnection(to: endpoint, using: parameters)
self.key = key
self.transferID = transferID
self.payloadHash = expectedHash
self.chunkBytes = chunkBytes
self.queue = queue
}
deinit {
connection.cancel()
}
func start() {
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .ready:
self.sendAuthFrameAndReceive()
case .failed(let error):
self.fail("connect failed: \(error)")
case .waiting(let error):
// .waiting can resolve on its own, but a per-transfer channel
// has a peer actively listening; treat unreachable as fatal so
// the sender's fallback isn't left to the window timeout alone.
self.fail("connection waiting: \(error)")
default:
break
}
}
connection.start(queue: queue)
}
func cancel() {
finished = true
connection.cancel()
}
private func sendAuthFrameAndReceive() {
guard !finished else { return }
let authBody: Data
do {
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
} catch {
fail("auth frame seal failed: \(error)")
return
}
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
guard let self, !self.finished else { return }
if let error {
self.fail("auth frame send failed: \(error)")
return
}
self.receiveChunks()
})
}
private func receiveChunks() {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
WifiBulkStream.readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
onFrame: { [weak self] body in
guard let self, !self.finished else { return false }
do {
guard let payload = try self.assembler.consume(frameBody: body) else {
return true // keep reading
}
self.sendReceiptAndComplete(payload)
return false
} catch {
self.fail("chunk rejected: \(error)")
return false
}
},
onError: { [weak self] reason in
self?.fail(reason)
}
)
}
private func sendReceiptAndComplete(_ payload: Data) {
let receiptBody: Data
do {
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
} catch {
fail("receipt seal failed: \(error)")
return
}
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
// Receipt is best-effort from the receiver's perspective: the
// payload is already verified. Close the channel either way.
guard let self, !self.finished else { return }
self.finished = true
self.connection.cancel()
self.onCompleted?(payload)
})
}
private func fail(_ reason: String) {
guard !finished else { return }
finished = true
connection.cancel()
onFailed?(reason)
}
}
@@ -0,0 +1,215 @@
//
// WifiBulkCrypto.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// Channel security for the Wi-Fi bulk data plane.
///
/// The TCP stream is encrypted and authenticated independently of TLS: both
/// endpoints exchanged random 32-byte tokens inside the established Noise
/// session, so only they can derive the ChaChaPoly channel key via
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
/// gatecrasher that connects to the listener cannot produce a single valid
/// frame and is disconnected.
///
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
/// combined form (12-byte nonce ciphertext 16-byte tag). Nonces are
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
/// and the reader requires the exact expected nonce for each frame, so frames
/// cannot be replayed, reordered, or reflected across directions.
enum WifiBulkCryptoError: Error, Equatable {
case invalidParameters
case frameTooLarge
case truncatedFrame
case nonceMismatch
case authenticationFailed
case emptyChunk
case payloadOverflow
case hashMismatch
}
enum WifiBulkFrameDirection: UInt8 {
/// Data chunks: counters 0, 1, 2,
case senderToReceiver = 0x00
/// Counter 0 = client auth frame, counter 1 = final receipt.
case receiverToSender = 0x01
}
enum WifiBulkCrypto {
static let keyDomain = "bitchat-bulk-v1"
static let nonceLength = 12
static let tagLength = 16
/// AEAD overhead per frame body (nonce + tag).
static let frameOverhead = nonceLength + tagLength
/// 4-byte big-endian length prefix per frame.
static let framePrefixLength = 4
// MARK: Key derivation
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
/// Deterministic: same tokens + transferID always yield the same key.
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
guard senderToken.count == WifiBulkWire.tokenLength,
receiverToken.count == WifiBulkWire.tokenLength,
transferID.count == WifiBulkWire.transferIDLength else {
return nil
}
var inputKeyMaterial = Data()
inputKeyMaterial.append(senderToken)
inputKeyMaterial.append(receiverToken)
return HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
salt: transferID,
info: Data(keyDomain.utf8),
outputByteCount: 32
)
}
// MARK: Frame sealing
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
var nonce = Data(count: nonceLength)
nonce[0] = direction.rawValue
var counterBE = counter.bigEndian
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
return nonce
}
/// Seals one frame body (nonce ciphertext tag), without length prefix.
static func sealFrameBody(
_ plaintext: Data,
direction: WifiBulkFrameDirection,
counter: UInt64,
key: SymmetricKey
) throws -> Data {
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
}
/// Opens one frame body, enforcing the exact expected nonce.
static func openFrameBody(
_ body: Data,
direction: WifiBulkFrameDirection,
counter: UInt64,
key: SymmetricKey
) throws -> Data {
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
throw WifiBulkCryptoError.nonceMismatch
}
do {
let box = try ChaChaPoly.SealedBox(combined: body)
return try ChaChaPoly.open(box, using: key)
} catch {
throw WifiBulkCryptoError.authenticationFailed
}
}
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
static func frameData(body: Data) -> Data {
var framed = Data(capacity: framePrefixLength + body.count)
var lengthBE = UInt32(body.count).bigEndian
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
framed.append(body)
return framed
}
// MARK: Control frames
/// First frame on the wire, receiver sender: proves the connecting
/// client holds the Noise-exchanged secret before any data flows.
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
}
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
}
/// Final frame, receiver sender: acknowledges the fully verified payload.
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
}
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
}
}
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
final class WifiBulkFrameBuffer {
private var buffer = Data()
private let maxBodyBytes: Int
init(maxBodyBytes: Int) {
self.maxBodyBytes = maxBodyBytes
}
func append(_ data: Data) {
buffer.append(data)
}
/// Extracts the next complete frame body, or nil when more bytes are needed.
func nextFrameBody() throws -> Data? {
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
return body
}
}
/// Receiver-side reassembly: opens sequential data frames, enforces the size
/// negotiated in the accepted offer, and verifies the final SHA-256.
final class WifiBulkPayloadAssembler {
private let key: SymmetricKey
private let expectedSize: Int
private let expectedHash: Data
private var received = Data()
private var counter: UInt64 = 0
/// Fails when the offer exceeds the receiver-enforced cap.
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
guard expectedSize > 0,
expectedSize <= UInt64(sizeCap),
expectedHash.count == WifiBulkWire.hashLength else {
return nil
}
self.key = key
self.expectedSize = Int(expectedSize)
self.expectedHash = expectedHash
}
var isComplete: Bool { received.count == expectedSize }
/// Consumes one sealed data frame body. Returns the verified payload when
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
func consume(frameBody: Data) throws -> Data? {
let chunk = try WifiBulkCrypto.openFrameBody(
frameBody,
direction: .senderToReceiver,
counter: counter,
key: key
)
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
counter += 1
guard received.count + chunk.count <= expectedSize else {
throw WifiBulkCryptoError.payloadOverflow
}
received.append(chunk)
guard isComplete else { return nil }
guard Data(SHA256.hash(data: received)) == expectedHash else {
throw WifiBulkCryptoError.hashMismatch
}
return received
}
}
@@ -0,0 +1,191 @@
//
// WifiBulkMessages.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
///
/// Both messages ride the encrypted Noise channel, so every field including
/// the session tokens and the random Bonjour instance name is only visible
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
/// compatibility.
enum WifiBulkWire {
static let transferIDLength = 16
static let tokenLength = 32
static let hashLength = 32
/// Bonjour instance names are capped at 63 UTF-8 bytes.
static let maxServiceNameBytes = 63
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
data.append(type)
var length = UInt16(value.count).bigEndian
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
data.append(value)
}
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
/// Returns false when the buffer is structurally malformed.
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
var cursor = data.startIndex
let end = data.endIndex
while cursor < end {
let type = data[cursor]
cursor = data.index(after: cursor)
guard data.distance(from: cursor, to: end) >= 2 else { return false }
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
cursor = data.index(cursor, offsetBy: 2)
guard data.distance(from: cursor, to: end) >= length else { return false }
let valueEnd = data.index(cursor, offsetBy: length)
visit(type, Data(data[cursor..<valueEnd]))
cursor = valueEnd
}
return true
}
}
/// Sender receiver: proposal to move an already-encoded file payload over
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
struct WifiBulkOffer: Equatable {
/// Random per-transfer identifier; also the HKDF salt.
let transferID: Data
/// Exact byte count of the payload that will cross the channel.
let fileSize: UInt64
/// SHA-256 over the payload bytes as they cross the channel, verified by
/// the receiver after reassembly.
let payloadHash: Data
/// Sender's random half of the channel secret.
let token: Data
/// Random Bonjour instance name the sender publishes for this transfer.
/// Never derived from nickname or peer ID.
let serviceName: String
private enum TLVType: UInt8 {
case transferID = 0x01
case fileSize = 0x02
case payloadHash = 0x03
case token = 0x04
case serviceName = 0x05
}
func encode() -> Data? {
guard transferID.count == WifiBulkWire.transferIDLength,
payloadHash.count == WifiBulkWire.hashLength,
token.count == WifiBulkWire.tokenLength else { return nil }
let nameData = Data(serviceName.utf8)
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
var encoded = Data()
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
var sizeBE = fileSize.bigEndian
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
return encoded
}
static func decode(_ data: Data) -> WifiBulkOffer? {
var transferID: Data?
var fileSize: UInt64?
var payloadHash: Data?
var token: Data?
var serviceName: String?
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
switch TLVType(rawValue: type) {
case .transferID where value.count == WifiBulkWire.transferIDLength:
transferID = value
case .fileSize where value.count == 8:
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .payloadHash where value.count == WifiBulkWire.hashLength:
payloadHash = value
case .token where value.count == WifiBulkWire.tokenLength:
token = value
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
serviceName = String(data: value, encoding: .utf8)
default:
break // Unknown or malformed field: ignore; required checks below.
}
}
guard wellFormed,
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
return nil
}
return WifiBulkOffer(
transferID: transferID,
fileSize: fileSize,
payloadHash: payloadHash,
token: token,
serviceName: serviceName
)
}
}
/// Receiver sender: accept (with the receiver's token half) or decline.
struct WifiBulkResponse: Equatable {
let transferID: Data
let accepted: Bool
/// Receiver's random half of the channel secret; present iff accepted.
let token: Data?
private enum TLVType: UInt8 {
case transferID = 0x01
case accepted = 0x02
case token = 0x03
}
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
}
static func decline(transferID: Data) -> WifiBulkResponse {
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
}
func encode() -> Data? {
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
if accepted {
guard token?.count == WifiBulkWire.tokenLength else { return nil }
}
var encoded = Data()
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
if accepted, let token {
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
}
return encoded
}
static func decode(_ data: Data) -> WifiBulkResponse? {
var transferID: Data?
var accepted: Bool?
var token: Data?
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
switch TLVType(rawValue: type) {
case .transferID where value.count == WifiBulkWire.transferIDLength:
transferID = value
case .accepted where value.count == 1:
accepted = value.first == 1
case .token where value.count == WifiBulkWire.tokenLength:
token = value
default:
break
}
}
guard wellFormed, let transferID, let accepted else { return nil }
if accepted {
guard let token else { return nil }
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
}
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
}
}
@@ -0,0 +1,57 @@
//
// WifiBulkPolicy.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
/// fails these gates rides BLE fragmentation exactly as before the BLE
/// fallback is the common case and must stay bulletproof.
enum WifiBulkPolicy {
struct SendCandidate {
let payloadBytes: Int
let peerCapabilities: PeerCapabilities
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
/// only reaches direct neighbors, and relays can't proxy the channel.
let isDirectlyConnected: Bool
/// The offer rides the Noise session, so one must already exist.
let hasEstablishedNoiseSession: Bool
}
static func shouldOffer(
_ candidate: SendCandidate,
enabled: Bool = TransportConfig.wifiBulkEnabled,
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
) -> Bool {
enabled
&& candidate.payloadBytes > minPayloadBytes
&& candidate.payloadBytes <= maxPayloadBytes
&& candidate.peerCapabilities.contains(.wifiBulk)
&& candidate.isDirectlyConnected
&& candidate.hasEstablishedNoiseSession
}
/// Receiver-side gate. Field lengths were validated at decode; this
/// enforces the size cap (from the local ceiling, not the sender's word)
/// and local enablement.
static func shouldAccept(
offer: WifiBulkOffer,
senderIsDirectlyConnected: Bool,
activeIncomingTransfers: Int,
enabled: Bool = TransportConfig.wifiBulkEnabled,
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
) -> Bool {
enabled
&& senderIsDirectlyConnected
&& activeIncomingTransfers < maxConcurrentIncoming
&& offer.fileSize > 0
&& offer.fileSize <= UInt64(maxPayloadBytes)
}
}
@@ -0,0 +1,477 @@
//
// WifiBulkTransferService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
import Network
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
/// hops live inside the closures supplied by `BLEService`, keeping this
/// service independently testable.
struct WifiBulkTransferServiceEnvironment {
/// Sends a typed payload inside the established Noise session with the
/// peer. Returns false when no established session exists (the caller
/// falls back to BLE).
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
/// Whether the peer is on a direct BLE link right now.
let isPeerConnected: (PeerID) -> Bool
/// Delivers a fully received, hash-verified payload (encoded
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
/// unchanged (chunks report as "fragments").
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
let progressChunkSent: (_ transferId: String) -> Void
/// Silently forgets progress state ahead of a BLE fallback re-start.
let progressReset: (_ transferId: String) -> Void
/// Emits the cancelled event for user-cancelled transfers.
let progressCancel: (_ transferId: String) -> Void
}
/// Knobs with test overrides; production values come from `TransportConfig`.
struct WifiBulkTransferServiceConfig {
var serviceType: String = TransportConfig.wifiBulkServiceType
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
/// Tests disable peer-to-peer so loopback interfaces stay usable.
var usePeerToPeer: Bool = true
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
var publishBonjourService: Bool = true
}
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
/// response (control plane), then the payload crosses a per-transfer TCP
/// channel over AWDL, sealed with a key both sides derived from the
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
/// fragmentation exactly once; the receiver side fails silently and lets the
/// sender's timeout drive that fallback.
final class WifiBulkTransferService {
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
private let environment: WifiBulkTransferServiceEnvironment
private let config: WifiBulkTransferServiceConfig
private final class OutgoingTransfer {
let transferID: Data
let transferId: String
let peerID: PeerID
let token: Data
let fallback: () -> Void
var session: WifiBulkSenderSession?
var offerTimeout: DispatchWorkItem?
var windowTimeout: DispatchWorkItem?
var accepted = false
var finished = false
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
self.transferID = transferID
self.transferId = transferId
self.peerID = peerID
self.token = token
self.fallback = fallback
}
}
private final class IncomingTransfer {
let offer: WifiBulkOffer
let peerID: PeerID
let key: SymmetricKey
var browser: NWBrowser?
var session: WifiBulkReceiverSession?
var windowTimeout: DispatchWorkItem?
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
self.offer = offer
self.peerID = peerID
self.key = key
}
}
private var outgoing: [Data: OutgoingTransfer] = [:]
private var incoming: [Data: IncomingTransfer] = [:]
init(
environment: WifiBulkTransferServiceEnvironment,
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
) {
self.environment = environment
self.config = config
}
// MARK: - Sender
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
/// most once, on decline, timeout, or any mid-transfer error.
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
queue.async { [weak self] in
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
}
}
/// Handles a decrypted `bulkTransferResponse` Noise payload.
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
queue.async { [weak self] in
self?.processResponse(payload, from: peerID)
}
}
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
func cancelTransfer(transferId: String) {
queue.async { [weak self] in
guard let self,
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
}
}
/// Tears down every transfer (service shutdown / emergency disconnect).
/// In-flight outgoing transfers do NOT fall back the transport is going away.
func stop() {
queue.async { [weak self] in
guard let self else { return }
for transfer in self.outgoing.values {
transfer.finished = true
transfer.offerTimeout?.cancel()
transfer.windowTimeout?.cancel()
transfer.session?.cancel()
}
self.outgoing.removeAll()
for transfer in self.incoming.values {
self.tearDownIncomingResources(transfer)
}
self.incoming.removeAll()
}
}
private enum OutgoingOutcome {
case completed
case fallback
case cancelled
}
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
let token = Self.randomData(WifiBulkWire.tokenLength)
// Random per-transfer instance name never the nickname or peer ID.
let serviceName = Self.randomData(16).hexEncodedString()
let offer = WifiBulkOffer(
transferID: transferID,
fileSize: UInt64(payload.count),
payloadHash: Data(SHA256.hash(data: payload)),
token: token,
serviceName: serviceName
)
guard let offerData = offer.encode() else {
fallbackToBLE()
return
}
let transfer = OutgoingTransfer(
transferID: transferID,
transferId: transferId,
peerID: peerID,
token: token,
fallback: fallbackToBLE
)
let session = WifiBulkSenderSession(
payload: payload,
transferID: transferID,
chunkBytes: config.chunkBytes,
parameters: makeParameters(),
service: config.publishBonjourService
? NWListener.Service(name: serviceName, type: config.serviceType)
: nil,
queue: queue
)
if let onListenerReady = _test_onListenerReady {
session.onListenerReady = { port in onListenerReady(transferID, port) }
}
session.onChunkSent = { [weak self, weak transfer] sent, total in
guard let self, let transfer, !transfer.finished else { return }
// Hold the final tick until the receipt confirms delivery, so the
// progress bus only emits .completed for verified transfers.
if sent < total {
self.environment.progressChunkSent(transfer.transferId)
}
}
session.onCompleted = { [weak self, weak transfer] in
guard let self, let transfer, !transfer.finished else { return }
self.environment.progressChunkSent(transfer.transferId)
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
}
session.onFailed = { [weak self, weak transfer] reason in
guard let self, let transfer else { return }
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
}
transfer.session = session
outgoing[transferID] = transfer
guard session.start() else {
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
return
}
guard environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
peerID
) else {
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
return
}
SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))", category: .session)
environment.progressStart(transferId, session.totalChunks)
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer, !transfer.accepted else { return }
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
}
transfer.offerTimeout = offerTimeout
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer else { return }
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
}
transfer.windowTimeout = windowTimeout
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
}
private func processResponse(_ payload: Data, from peerID: PeerID) {
guard let response = WifiBulkResponse.decode(payload),
let transfer = outgoing[response.transferID],
transfer.peerID.toShort() == peerID.toShort(),
!transfer.accepted, !transfer.finished else {
return
}
guard response.accepted, let receiverToken = response.token else {
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
return
}
guard let key = WifiBulkCrypto.deriveKey(
senderToken: transfer.token,
receiverToken: receiverToken,
transferID: transfer.transferID
) else {
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
return
}
transfer.accepted = true
transfer.offerTimeout?.cancel()
transfer.offerTimeout = nil
transfer.session?.activate(key: key)
}
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
guard !transfer.finished else { return }
transfer.finished = true
transfer.offerTimeout?.cancel()
transfer.windowTimeout?.cancel()
transfer.session?.cancel()
outgoing.removeValue(forKey: transfer.transferID)
switch outcome {
case .completed:
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session)
case .fallback:
SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8))… falling back to BLE (\(reason))", category: .session)
environment.progressReset(transfer.transferId)
transfer.fallback()
case .cancelled:
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session)
environment.progressCancel(transfer.transferId)
}
}
// MARK: - Receiver
/// Handles a decrypted `bulkTransferOffer` Noise payload.
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
queue.async { [weak self] in
self?.processOffer(payload, from: peerID)
}
}
private func processOffer(_ payload: Data, from peerID: PeerID) {
guard let offer = WifiBulkOffer.decode(payload) else { return }
guard incoming[offer.transferID] == nil else { return }
guard WifiBulkPolicy.shouldAccept(
offer: offer,
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
activeIncomingTransfers: incoming.count,
maxPayloadBytes: config.maxIncomingPayloadBytes,
maxConcurrentIncoming: config.maxConcurrentIncoming
) else {
decline(offer: offer, peerID: peerID)
return
}
let token = Self.randomData(WifiBulkWire.tokenLength)
guard let key = WifiBulkCrypto.deriveKey(
senderToken: offer.token,
receiverToken: token,
transferID: offer.transferID
),
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
decline(offer: offer, peerID: peerID)
return
}
guard environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
peerID
) else {
return // No session to answer on; the sender's timeout handles fallback.
}
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
incoming[offer.transferID] = transfer
SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .session)
startBrowsing(for: transfer)
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer else { return }
SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session)
self.tearDownIncoming(transfer)
}
transfer.windowTimeout = windowTimeout
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
}
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .session)
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
_ = environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
peerID
)
}
private func startBrowsing(for transfer: IncomingTransfer) {
let browser = NWBrowser(
for: .bonjour(type: config.serviceType, domain: nil),
using: makeParameters()
)
transfer.browser = browser
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
guard let self, let transfer, transfer.session == nil else { return }
let match = results.first { result in
if case .service(let name, _, _, _) = result.endpoint {
return name == transfer.offer.serviceName
}
return false
}
guard let match else { return }
self.connect(transfer, to: match.endpoint)
}
browser.stateUpdateHandler = { [weak self, weak transfer] state in
guard let self, let transfer else { return }
if case .failed(let error) = state {
SecureLogger.warning("WifiBulk: browser failed: \(error)", category: .session)
self.tearDownIncoming(transfer)
}
}
browser.start(queue: queue)
}
/// Test hook: connects an accepted incoming transfer straight to an
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
queue.async { [weak self] in
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
self.connect(transfer, to: endpoint)
}
}
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
transfer.browser?.cancel()
transfer.browser = nil
guard let session = WifiBulkReceiverSession(
endpoint: endpoint,
parameters: makeParameters(),
key: transfer.key,
transferID: transfer.offer.transferID,
expectedSize: transfer.offer.fileSize,
expectedHash: transfer.offer.payloadHash,
sizeCap: config.maxIncomingPayloadBytes,
chunkBytes: config.chunkBytes,
queue: queue
) else {
tearDownIncoming(transfer)
return
}
session.onCompleted = { [weak self, weak transfer] payload in
guard let self, let transfer else { return }
SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))", category: .session)
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
self.tearDownIncoming(transfer)
}
session.onFailed = { [weak self, weak transfer] reason in
guard let self, let transfer else { return }
SecureLogger.info("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session)
self.tearDownIncoming(transfer)
}
transfer.session = session
session.start()
}
private func tearDownIncoming(_ transfer: IncomingTransfer) {
tearDownIncomingResources(transfer)
incoming.removeValue(forKey: transfer.offer.transferID)
}
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
transfer.windowTimeout?.cancel()
transfer.windowTimeout = nil
transfer.browser?.cancel()
transfer.browser = nil
transfer.session?.cancel()
transfer.session = nil
}
// MARK: - Helpers
private func makeParameters() -> NWParameters {
let parameters = NWParameters.tcp
if config.usePeerToPeer {
parameters.includePeerToPeer = true
// Keep the channel off infrastructure-independent radios we never
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
}
return parameters
}
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
private static func randomData(_ count: Int) -> Data {
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
}
// MARK: - Test observability
/// Test hook: reports each outgoing listener's bound port, standing in
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
var _test_activeOutgoingCount: Int {
queue.sync { outgoing.count }
}
var _test_activeIncomingCount: Int {
queue.sync { incoming.count }
}
}
@@ -371,6 +371,11 @@ private extension ChatTransportEventCoordinator {
case .verifyResponse:
context.handleVerifyResponsePayload(from: peerID, payload: payload)
case .bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is consumed inside the mesh transport
// (BLEService); it never reaches the UI layer.
break
}
}
+10 -3
View File
@@ -297,7 +297,9 @@ final class NostrInboundPipeline {
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
case .verifyChallenge, .verifyResponse,
.bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
break
}
}
@@ -349,7 +351,9 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
case .verifyChallenge, .verifyResponse,
.bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
break
}
}
@@ -428,7 +432,10 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
case .verifyChallenge, .verifyResponse,
.bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is mesh-proximity only;
// it never rides Nostr.
break
}
}
@@ -0,0 +1,264 @@
//
// WifiBulkCryptoTests.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 Testing
@testable import bitchat
@Suite("WifiBulk channel crypto")
struct WifiBulkCryptoTests {
private static func keyHex(_ key: SymmetricKey) -> String {
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
}
private func makeKey() throws -> SymmetricKey {
try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x11, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
}
// MARK: HKDF derivation
@Test("HKDF derivation matches fixed vectors")
func hkdfVectors() throws {
// Vectors computed independently with CryptoKit HKDF<SHA256>,
// ikm = senderToken receiverToken, salt = transferID,
// info = "bitchat-bulk-v1", 32 bytes out.
let key1 = try makeKey()
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
let key2 = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data((0..<32).map { UInt8($0) }),
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
transferID: Data((0..<16).map { UInt8($0 * 3) })
))
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
}
@Test("HKDF derivation is order- and role-sensitive")
func hkdfRoleSensitivity() throws {
let a = Data(repeating: 0x11, count: 32)
let b = Data(repeating: 0x22, count: 32)
let tid = Data(repeating: 0x33, count: 16)
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
}
@Test("HKDF derivation rejects wrong-length inputs")
func hkdfRejectsBadLengths() {
let token = Data(repeating: 1, count: 32)
let tid = Data(repeating: 2, count: 16)
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
}
// MARK: Frame sealing
@Test("Frame seal/open round-trips")
func frameRoundTrip() throws {
let key = try makeKey()
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
#expect(opened == plaintext)
}
@Test("Tampered frames are rejected")
func tamperRejection() throws {
let key = try makeKey()
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
body[body.count - 1] ^= 0x01 // flip a tag bit
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Frames cannot be replayed at another counter or reflected across directions")
func nonceBinding() throws {
let key = try makeKey()
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
}
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
}
}
@Test("Frames sealed under a different key are rejected")
func wrongKeyRejection() throws {
let key = try makeKey()
let otherKey = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x44, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Auth and receipt control frames validate and reject forgeries")
func controlFrames() throws {
let key = try makeKey()
let transferID = Data(repeating: 0x33, count: 16)
let hash = Data(repeating: 0x55, count: 32)
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
var forgedAuth = auth
forgedAuth[forgedAuth.count - 1] ^= 0x01
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
// An auth frame is not a receipt (distinct counter).
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
}
// MARK: Frame buffer
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
func frameBufferReassembly() throws {
let bodyA = Data(repeating: 0xAA, count: 100)
let bodyB = Data(repeating: 0xBB, count: 5)
var stream = WifiBulkCrypto.frameData(body: bodyA)
stream.append(WifiBulkCrypto.frameData(body: bodyB))
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
// Drip-feed 3 bytes at a time.
var extracted: [Data] = []
var index = stream.startIndex
while index < stream.endIndex {
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
buffer.append(Data(stream[index..<next]))
while let body = try buffer.nextFrameBody() {
extracted.append(body)
}
index = next
}
#expect(extracted == [bodyA, bodyB])
#expect(try buffer.nextFrameBody() == nil)
}
@Test("Frame buffer rejects oversized frame lengths without buffering them")
func frameBufferOversizeRejection() {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
_ = try buffer.nextFrameBody()
}
}
// MARK: Payload assembler
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
try WifiBulkCrypto.sealFrameBody(
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
direction: .senderToReceiver,
counter: UInt64(index),
key: key
)
}
}
@Test("Assembler reassembles and verifies a chunked payload")
func assemblerHappyPath() throws {
let key = try makeKey()
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
var result: Data?
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
result = try assembler.consume(frameBody: chunk)
}
#expect(result == payload)
}
@Test("Assembler rejects a payload whose final hash mismatches the offer")
func assemblerHashMismatch() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(repeating: 0, count: 32), // wrong hash
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
_ = try assembler.consume(frameBody: chunks[0])
#expect(throws: WifiBulkCryptoError.hashMismatch) {
_ = try assembler.consume(frameBody: chunks[1])
}
}
@Test("Assembler rejects overflow beyond the offered size")
func assemblerOverflow() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 1000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: 500, // offer promised less than the sender streams
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
_ = try assembler.consume(frameBody: chunk)
}
}
@Test("Assembler enforces the receiver-side size cap at construction")
func assemblerSizeCap() throws {
let key = try makeKey()
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: 0,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
}
@Test("Assembler rejects out-of-order chunks")
func assemblerOutOfOrder() throws {
let key = try makeKey()
let payload = Data(repeating: 0x42, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
}
}
}
@@ -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)
}
}
@@ -0,0 +1,107 @@
//
// WifiBulkMessagesTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk offer/response TLV")
struct WifiBulkMessagesTests {
private func makeOffer(
fileSize: UInt64 = 300_000,
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
serviceName: serviceName
)
}
@Test("Offer round-trips through TLV encoding")
func offerRoundTrip() throws {
let offer = makeOffer()
let encoded = try #require(offer.encode())
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == offer)
}
@Test("Offer encode rejects malformed field lengths")
func offerEncodeRejectsBadFields() {
#expect(WifiBulkOffer(
transferID: Data(repeating: 1, count: 15), // short transferID
fileSize: 1,
payloadHash: Data(repeating: 2, count: 32),
token: Data(repeating: 3, count: 32),
serviceName: "x"
).encode() == nil)
#expect(makeOffer(serviceName: "").encode() == nil)
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
}
@Test("Offer decode rejects missing or wrong-length fields")
func offerDecodeRejectsMalformed() throws {
let encoded = try #require(makeOffer().encode())
// Truncation anywhere breaks a TLV boundary or drops a required field.
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
#expect(WifiBulkOffer.decode(Data()) == nil)
// A wrong-length transferID TLV is ignored, leaving the field missing.
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
#expect(WifiBulkOffer.decode(mangled) == nil)
}
@Test("Offer decode skips unknown TLVs for forward compatibility")
func offerDecodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeOffer().encode())
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == makeOffer())
}
@Test("Accept response round-trips with token")
func acceptResponseRoundTrip() throws {
let response = WifiBulkResponse.accept(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(decoded.accepted)
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
}
@Test("Decline response round-trips without token")
func declineResponseRoundTrip() throws {
let response = WifiBulkResponse.decline(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(!decoded.accepted)
#expect(decoded.token == nil)
}
@Test("Accept response without a token is rejected")
func acceptWithoutTokenRejected() throws {
// Hand-build: transferID + accepted=1, no token TLV.
var data = Data()
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
#expect(WifiBulkResponse.decode(data) == nil)
}
}
@@ -0,0 +1,150 @@
//
// WifiBulkPolicyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk policy")
struct WifiBulkPolicyTests {
private func candidate(
payloadBytes: Int = 300_000,
capabilities: PeerCapabilities = [.wifiBulk],
direct: Bool = true,
session: Bool = true
) -> WifiBulkPolicy.SendCandidate {
WifiBulkPolicy.SendCandidate(
payloadBytes: payloadBytes,
peerCapabilities: capabilities,
isDirectlyConnected: direct,
hasEstablishedNoiseSession: session
)
}
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
func eligibleTransferOffered() {
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
}
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
func fallbackMatrix() {
// Feature disabled.
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
// Small file: negotiation overhead not worth it.
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
// Peer doesn't advertise the capability.
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
// Multi-hop recipient (reachable but not directly connected).
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
// No established Noise session to carry the offer.
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
// Payload beyond even the Wi-Fi ceiling.
#expect(!WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
enabled: true
))
}
@Test("Offer threshold is strictly greater than the minimum")
func offerThresholdBoundary() {
#expect(!WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
enabled: true
))
#expect(WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
enabled: true
))
}
private func offer(fileSize: UInt64) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
serviceName: "0011223344556677"
)
}
@Test("Receiver accepts an in-cap offer from a direct peer")
func receiverAccepts() {
#expect(WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1_000_000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
}
@Test("Receiver enforces its own size cap, not the sender's word")
func receiverSizeCap() {
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
#expect(WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 0),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
}
@Test("Receiver declines when disabled, indirect, or saturated")
func receiverDeclines() {
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: false
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: false,
activeIncomingTransfers: 0,
enabled: true
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
enabled: true
))
}
@Test("This build advertises the wifiBulk capability when enabled")
func localCapabilityAdvertised() {
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
}
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
func filePacketWifiLimit() throws {
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
// BLE cap unchanged.
#expect(packet.encode() == nil)
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
#expect(decoded.content == content)
#expect(decoded.fileName == "big.jpg")
}
}
@@ -0,0 +1,246 @@
//
// WifiBulkTransferServiceTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Network
import Testing
@testable import bitchat
/// Thread-safe capture box for closures invoked on service queues.
final class WifiBulkTestBox<Value>: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Value] = []
func append(_ value: Value) {
lock.lock()
storage.append(value)
lock.unlock()
}
var values: [Value] {
lock.lock()
defer { lock.unlock() }
return storage
}
var count: Int { values.count }
}
func wifiBulkWait(
timeout: TimeInterval = 5.0,
_ condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() { return true }
try? await Task.sleep(nanoseconds: 20_000_000)
}
return condition()
}
/// Negotiation/fallback decisions exercised through the real service with the
/// network kept on loopback and Bonjour publication disabled.
@Suite("WifiBulk transfer service negotiation", .serialized)
struct WifiBulkTransferServiceTests {
private static let peer = PeerID(str: "aabbccddeeff0011")
private func makeConfig() -> WifiBulkTransferServiceConfig {
var config = WifiBulkTransferServiceConfig()
config.usePeerToPeer = false
config.publishBonjourService = false
config.offerTimeout = 0.25
config.transferWindow = 2.0
return config
}
private func makeEnvironment(
sendNoisePayload: @escaping (Data, PeerID) -> Bool,
deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in },
progressEvents: WifiBulkTestBox<String>? = nil
) -> WifiBulkTransferServiceEnvironment {
WifiBulkTransferServiceEnvironment(
sendNoisePayload: sendNoisePayload,
isPeerConnected: { _ in true },
deliverReceivedFile: deliver,
progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") },
progressChunkSent: { id in progressEvents?.append("chunk:\(id)") },
progressReset: { id in progressEvents?.append("reset:\(id)") },
progressCancel: { id in progressEvents?.append("cancel:\(id)") }
)
}
private var payload: Data { Data(repeating: 0x42, count: 100_000) }
@Test("No established Noise session falls straight back to BLE")
func noSessionFallsBack() async {
let fallbacks = WifiBulkTestBox<Bool>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in false }),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
#expect(service._test_activeOutgoingCount == 0)
}
@Test("Unanswered offer times out and falls back exactly once")
func offerTimeoutFallsBackOnce() async {
let fallbacks = WifiBulkTestBox<Bool>()
let progress = WifiBulkTestBox<String>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
// Wait past the transfer window: the expiry must not double-fire.
try? await Task.sleep(nanoseconds: 2_300_000_000)
#expect(fallbacks.count == 1)
#expect(service._test_activeOutgoingCount == 0)
// Progress state was silently reset ahead of the BLE re-start.
#expect(progress.values.contains("reset:t-timeout"))
#expect(!progress.values.contains("cancel:t-timeout"))
}
@Test("Declined offer falls back exactly once, even on duplicate declines")
func declineFallsBackOnce() async {
let fallbacks = WifiBulkTestBox<Bool>()
let offers = WifiBulkTestBox<Data>()
var service: WifiBulkTransferService?
let environment = makeEnvironment(sendNoisePayload: { typed, peer in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true }
offers.append(offer.transferID)
guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
// Deliver the decline twice; the fallback must still fire once.
service?.handleResponsePayload(decline, from: peer)
service?.handleResponsePayload(decline, from: peer)
return true
})
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
service = sut
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(fallbacks.count == 1)
#expect(sut._test_activeOutgoingCount == 0)
}
@Test("Responses from the wrong peer are ignored")
func wrongPeerResponseIgnored() async {
let fallbacks = WifiBulkTestBox<Bool>()
var service: WifiBulkTransferService?
let environment = makeEnvironment(sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
let offer = WifiBulkOffer.decode(typed.dropFirst()),
let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
// Decline arrives from an unrelated peer: must be ignored, so the
// transfer ends via offer timeout instead.
service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788"))
return true
})
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
service = sut
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") {
fallbacks.append(true)
}
// Not fallen back before the offer timeout window
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(fallbacks.count == 0)
// but the timeout still cleans up.
#expect(await wifiBulkWait { fallbacks.count == 1 })
}
@Test("User cancel tears down without BLE fallback")
func userCancelDoesNotFallBack() async {
let fallbacks = WifiBulkTestBox<Bool>()
let progress = WifiBulkTestBox<String>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
service.cancelTransfer(transferId: "t-cancel")
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout
#expect(fallbacks.count == 0)
#expect(progress.values.contains("cancel:t-cancel"))
}
@Test("Receiver declines an offer that exceeds its size cap")
func receiverDeclinesOversizedOffer() async {
let responses = WifiBulkTestBox<Data>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { typed, _ in
if typed.first == NoisePayloadType.bulkTransferResponse.rawValue {
responses.append(Data(typed.dropFirst()))
}
return true
}),
config: makeConfig()
)
let offer = WifiBulkOffer(
transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength),
fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength),
token: Data(repeating: 9, count: WifiBulkWire.tokenLength),
serviceName: "0011223344556677"
)
if let encoded = offer.encode() {
service.handleOfferPayload(encoded, from: Self.peer)
}
#expect(await wifiBulkWait { responses.count == 1 })
let response = WifiBulkResponse.decode(responses.values[0])
#expect(response?.accepted == false)
#expect(response?.transferID == offer.transferID)
#expect(service._test_activeIncomingCount == 0)
}
@Test("Malformed offers are dropped without a response")
func malformedOfferDropped() async {
let responses = WifiBulkTestBox<Data>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { typed, _ in
responses.append(typed)
return true
}),
config: makeConfig()
)
service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer)
try? await Task.sleep(nanoseconds: 200_000_000)
#expect(responses.count == 0)
#expect(service._test_activeIncomingCount == 0)
}
@Test("stop() tears down active transfers without falling back")
func stopTearsDownWithoutFallback() async {
let fallbacks = WifiBulkTestBox<Bool>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
service.stop()
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
try? await Task.sleep(nanoseconds: 400_000_000)
#expect(fallbacks.count == 0)
}
}
@@ -2,6 +2,10 @@
public enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other).
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Ceiling for transfers negotiated onto the peer-to-peer Wi-Fi bulk
/// channel. The receiver enforces it against the size in the accepted
/// offer; the Bluetooth path keeps `maxPayloadBytes`.
public static let maxWifiBulkPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB
/// Voice notes stay small for low-latency relays.
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
/// Compressed images after downscaling should comfortably fit under this budget.
@@ -17,7 +21,7 @@ public enum FileTransferLimits {
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}()
public static func isValidPayload(_ size: Int) -> Bool {
size <= maxPayloadBytes
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool {
size <= limit
}
}