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