diff --git a/bitchat/Info.plist b/bitchat/Info.plist
index f949ecee..1a6d4fbb 100644
--- a/bitchat/Info.plist
+++ b/bitchat/Info.plist
@@ -33,12 +33,18 @@
$(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
+ NSBonjourServices
+
+ _bitchat-bulk._tcp
+
NSBluetoothAlwaysUsageDescription
bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription
bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription
bitchat uses the camera to scan QR codes to verify peers.
+ NSLocalNetworkUsageDescription
+ bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.
NSLocationWhenInUseUsageDescription
bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
NSMicrophoneUsageDescription
diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift
index 6002744d..0965bee5 100644
--- a/bitchat/Protocols/BitchatFilePacket.swift
+++ b/bitchat/Protocols/BitchatFilePacket.swift
@@ -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(_ 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),
diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift
index 4ebe6509..37142bc6 100644
--- a/bitchat/Protocols/BitchatProtocol.swift
+++ b/bitchat/Protocols/BitchatProtocol.swift
@@ -72,15 +72,20 @@ 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
-
+
var description: String {
switch self {
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"
}
diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift
index 40f97b9f..59145fc6 100644
--- a/bitchat/Protocols/PeerCapabilities+Local.swift
+++ b/bitchat/Protocols/PeerCapabilities+Local.swift
@@ -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] : []
}
diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift
index 43d13aa9..ac2854a2 100644
--- a/bitchat/Services/BLE/BLEFileTransferHandler.swift
+++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift
@@ -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
diff --git a/bitchat/Services/BLE/BLEFileTransferPolicy.swift b/bitchat/Services/BLE/BLEFileTransferPolicy.swift
index 0eedc429..981e80a9 100644
--- a/bitchat/Services/BLE/BLEFileTransferPolicy.swift
+++ b/bitchat/Services/BLE/BLEFileTransferPolicy.swift
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
}
enum BLEIncomingFileValidator {
- static func validate(payload: Data) -> Result {
- 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 {
+ 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))
}
diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift
index d1ed84f2..6201ce8c 100644
--- a/bitchat/Services/BLE/BLEService.swift
+++ b/bitchat/Services/BLE/BLEService.swift
@@ -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 started→cancelled 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,
diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift
index 59d15c55..078f298d 100644
--- a/bitchat/Services/TransportConfig.swift
+++ b/bitchat/Services/TransportConfig.swift
@@ -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
diff --git a/bitchat/Services/WifiBulk/WifiBulkChannel.swift b/bitchat/Services/WifiBulk/WifiBulkChannel.swift
new file mode 100644
index 00000000..af0b67c5
--- /dev/null
+++ b/bitchat/Services/WifiBulk/WifiBulkChannel.swift
@@ -0,0 +1,463 @@
+//
+// WifiBulkChannel.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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.. 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)
+ }
+}
diff --git a/bitchat/Services/WifiBulk/WifiBulkCrypto.swift b/bitchat/Services/WifiBulk/WifiBulkCrypto.swift
new file mode 100644
index 00000000..6db02f3b
--- /dev/null
+++ b/bitchat/Services/WifiBulk/WifiBulkCrypto.swift
@@ -0,0 +1,215 @@
+//
+// WifiBulkCrypto.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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.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.. 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
+ }
+}
diff --git a/bitchat/Services/WifiBulk/WifiBulkMessages.swift b/bitchat/Services/WifiBulk/WifiBulkMessages.swift
new file mode 100644
index 00000000..8a718a8c
--- /dev/null
+++ b/bitchat/Services/WifiBulk/WifiBulkMessages.swift
@@ -0,0 +1,191 @@
+//
+// WifiBulkMessages.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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.. 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)
+ }
+}
diff --git a/bitchat/Services/WifiBulk/WifiBulkPolicy.swift b/bitchat/Services/WifiBulk/WifiBulkPolicy.swift
new file mode 100644
index 00000000..8edddee3
--- /dev/null
+++ b/bitchat/Services/WifiBulk/WifiBulkPolicy.swift
@@ -0,0 +1,57 @@
+//
+// WifiBulkPolicy.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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)
+ }
+}
diff --git a/bitchat/Services/WifiBulk/WifiBulkTransferService.swift b/bitchat/Services/WifiBulk/WifiBulkTransferService.swift
new file mode 100644
index 00000000..f4ff408a
--- /dev/null
+++ b/bitchat/Services/WifiBulk/WifiBulkTransferService.swift
@@ -0,0 +1,477 @@
+//
+// WifiBulkTransferService.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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.. Void)?
+
+ var _test_activeOutgoingCount: Int {
+ queue.sync { outgoing.count }
+ }
+
+ var _test_activeIncomingCount: Int {
+ queue.sync { incoming.count }
+ }
+}
diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift
index 75571b2e..c4ba6371 100644
--- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift
+++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift
@@ -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
}
}
diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift
index 337fa474..caceb932 100644
--- a/bitchat/ViewModels/NostrInboundPipeline.swift
+++ b/bitchat/ViewModels/NostrInboundPipeline.swift
@@ -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
}
}
diff --git a/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift b/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift
new file mode 100644
index 00000000..adc93142
--- /dev/null
+++ b/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift
@@ -0,0 +1,264 @@
+//
+// WifiBulkCryptoTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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,
+ // 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.. [Data] {
+ try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
+ try WifiBulkCrypto.sealFrameBody(
+ Data(payload[offset..
+//
+
+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()
+ let progress = WifiBulkTestBox()
+ let fallbacks = WifiBulkTestBox()
+ let ports = WifiBulkTestBox<(Data, UInt16)>()
+ let offers = WifiBulkTestBox()
+
+ 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()
+ 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()
+ let fallbacks = WifiBulkTestBox()
+ let ports = WifiBulkTestBox<(Data, UInt16)>()
+ let accepted = WifiBulkTestBox()
+
+ var senderService: WifiBulkTransferService?
+ var receiverService: WifiBulkTransferService?
+
+ let gatecrashed = WifiBulkTestBox()
+ 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()
+ 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)
+ }
+}
diff --git a/bitchatTests/WifiBulk/WifiBulkMessagesTests.swift b/bitchatTests/WifiBulk/WifiBulkMessagesTests.swift
new file mode 100644
index 00000000..1cc92657
--- /dev/null
+++ b/bitchatTests/WifiBulk/WifiBulkMessagesTests.swift
@@ -0,0 +1,107 @@
+//
+// WifiBulkMessagesTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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)
+ }
+}
diff --git a/bitchatTests/WifiBulk/WifiBulkPolicyTests.swift b/bitchatTests/WifiBulk/WifiBulkPolicyTests.swift
new file mode 100644
index 00000000..82a6c512
--- /dev/null
+++ b/bitchatTests/WifiBulk/WifiBulkPolicyTests.swift
@@ -0,0 +1,150 @@
+//
+// WifiBulkPolicyTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+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")
+ }
+}
diff --git a/bitchatTests/WifiBulk/WifiBulkTransferServiceTests.swift b/bitchatTests/WifiBulk/WifiBulkTransferServiceTests.swift
new file mode 100644
index 00000000..d14f5dd9
--- /dev/null
+++ b/bitchatTests/WifiBulk/WifiBulkTransferServiceTests.swift
@@ -0,0 +1,246 @@
+//
+// WifiBulkTransferServiceTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitFoundation
+import Foundation
+import Network
+import Testing
+@testable import bitchat
+
+/// Thread-safe capture box for closures invoked on service queues.
+final class WifiBulkTestBox: @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? = 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()
+ 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()
+ let progress = WifiBulkTestBox()
+ 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()
+ let offers = WifiBulkTestBox()
+ 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()
+ 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()
+ let progress = WifiBulkTestBox()
+ 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()
+ 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()
+ 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()
+ 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)
+ }
+}
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift b/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift
index 4c54f79c..ec89c4b6 100644
--- a/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift
+++ b/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift
@@ -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
}
}