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