mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8256bf72 | ||
|
|
2b7fd2002b | ||
|
|
e4b3cff5fa | ||
|
|
688b954fb8 |
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
private static let targetImageBytes: Int = 45_000
|
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
|
||||||
|
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
|
||||||
|
// `compressionQuality`, and only steps quality down when a payload would
|
||||||
|
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
|
||||||
|
// (512 KiB) so the BLE path never overruns its cap.
|
||||||
|
//
|
||||||
|
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
|
||||||
|
// to ~40 KB — below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) — so
|
||||||
|
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
|
||||||
|
// in production. A genuinely detailed photo at `defaultMaxDimension` now
|
||||||
|
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
|
||||||
|
// direct peer while still riding BLE fragmentation for everyone else.
|
||||||
|
private static let targetImageBytes: Int = 200_000
|
||||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||||
|
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
|
||||||
|
// (together with the tiny byte budget) forced every photo below the Wi-Fi
|
||||||
|
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
|
||||||
|
// images clear 64 KiB, without approaching the 512 KiB hard cap.
|
||||||
|
static let defaultMaxDimension: CGFloat = 1024
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||||
try validateImageSource(at: url)
|
try validateImageSource(at: url)
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
@@ -47,34 +65,33 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
var dimension = maxDimension
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
var jpegData: Data?
|
||||||
|
// Downscale-and-compress until the payload fits the hard image cap.
|
||||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
// A normal photo converges on the first pass; this loop only kicks
|
||||||
guard let cgImage = scaled.cgImage else {
|
// in for near-incompressible inputs (e.g. full-frame noise) that
|
||||||
throw ImageUtilsError.encodingFailed
|
// would otherwise overrun `maxImageBytes` at the raised dimension.
|
||||||
}
|
while true {
|
||||||
|
let scaled = scaledImage(image, maxDimension: dimension)
|
||||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
var quality = compressionQuality
|
guard let cgImage = scaled.cgImage else {
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
throw ImageUtilsError.encodingFailed
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compress to target size
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
guard let data = compressToBudget(cgImage) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
jpegData = data
|
||||||
|
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||||
}
|
}
|
||||||
|
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||||
|
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try finalData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,66 +110,49 @@ enum ImageUtils {
|
|||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
return rendered ?? image
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
|
||||||
// By only specifying compression quality and no metadata keys,
|
|
||||||
// we ensure a clean JPEG with no privacy-leaking information
|
|
||||||
let options: [CFString: Any] = [
|
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
|
||||||
]
|
|
||||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
||||||
guard CGImageDestinationFinalize(destination) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return data as Data
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
var dimension = maxDimension
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
var jpegData: Data?
|
||||||
throw ImageUtilsError.encodingFailed
|
// See the iOS path: normal photos converge immediately; the loop
|
||||||
}
|
// only shrinks further for near-incompressible inputs so the
|
||||||
let width = inputCG.width
|
// output never overruns `maxImageBytes`.
|
||||||
let height = inputCG.height
|
while true {
|
||||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
let scaled = scaledImage(image, maxDimension: dimension)
|
||||||
guard let context = CGContext(
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
data: nil,
|
throw ImageUtilsError.encodingFailed
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
bitsPerComponent: 8,
|
|
||||||
bytesPerRow: 0,
|
|
||||||
space: colorSpace,
|
|
||||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
||||||
) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
guard let cgImage = context.makeImage() else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let width = inputCG.width
|
||||||
|
let height = inputCG.height
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
guard let data = compressToBudget(cgImage) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
jpegData = data
|
||||||
|
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||||
}
|
}
|
||||||
|
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try finalData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,6 +172,31 @@ enum ImageUtils {
|
|||||||
scaledImage.unlockFocus()
|
scaledImage.unlockFocus()
|
||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// When even the quality floor can't get an image under the byte budget,
|
||||||
|
// shrink the longest side by this factor and re-encode. Bounded below so
|
||||||
|
// the retry loop always terminates.
|
||||||
|
private static let dimensionRetryFactor: CGFloat = 0.75
|
||||||
|
private static let minRetryDimension: CGFloat = 256
|
||||||
|
|
||||||
|
/// Encodes `cgImage` to JPEG, stepping quality down toward
|
||||||
|
/// `targetImageBytes`. Shared by both platforms.
|
||||||
|
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jpegData
|
||||||
|
}
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
@@ -193,7 +218,6 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
|
|||||||
@@ -33,12 +33,18 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_bitchat-bulk._tcp</string>
|
||||||
|
</array>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
|||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
func encode() -> Data? {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||||
|
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||||
|
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
var big = value.bigEndian
|
var big = value.bigEndian
|
||||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||||
|
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||||
|
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||||
var cursor = data.startIndex
|
var cursor = data.startIndex
|
||||||
let end = data.endIndex
|
let end = data.endIndex
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
|||||||
for byte in value {
|
for byte in value {
|
||||||
size = (size << 8) | UInt64(byte)
|
size = (size << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
if size > UInt64(limit) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fileSize = size
|
fileSize = size
|
||||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
|||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
let proposedSize = content.count + value.count
|
let proposedSize = content.count + value.count
|
||||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
if proposedSize > limit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.append(contentsOf: value)
|
content.append(contentsOf: value)
|
||||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
guard !content.isEmpty else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||||
return BitchatFilePacket(
|
return BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
|
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
||||||
|
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
||||||
|
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
@@ -81,6 +84,8 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||||
|
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Protocol TLV Packets
|
// MARK: - Protocol TLV Packets
|
||||||
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
|
|||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||||
|
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||||
|
|
||||||
|
init(
|
||||||
|
nickname: String,
|
||||||
|
noisePublicKey: Data,
|
||||||
|
signingPublicKey: Data,
|
||||||
|
directNeighbors: [Data]?,
|
||||||
|
capabilities: PeerCapabilities? = nil
|
||||||
|
) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.noisePublicKey = noisePublicKey
|
||||||
|
self.signingPublicKey = signingPublicKey
|
||||||
|
self.directNeighbors = directNeighbors
|
||||||
|
self.capabilities = capabilities
|
||||||
|
}
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
case directNeighbors = 0x04
|
||||||
|
case capabilities = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -48,6 +65,15 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLV for capabilities (optional)
|
||||||
|
if let capabilities = capabilities {
|
||||||
|
let capabilityBytes = capabilities.encoded()
|
||||||
|
guard capabilityBytes.count <= 255 else { return nil }
|
||||||
|
data.append(TLVType.capabilities.rawValue)
|
||||||
|
data.append(UInt8(capabilityBytes.count))
|
||||||
|
data.append(capabilityBytes)
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
|
|||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
|
var capabilities: PeerCapabilities?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
directNeighbors = neighbors
|
directNeighbors = neighbors
|
||||||
}
|
}
|
||||||
|
case .capabilities:
|
||||||
|
capabilities = PeerCapabilities(encoded: Data(value))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors
|
directNeighbors: directNeighbors,
|
||||||
|
capabilities: capabilities
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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 = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||||
|
/// pass the ceiling that was enforced against the accepted offer.
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
|
|||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
enum BLEIncomingFileValidator {
|
||||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
/// pass the ceiling enforced against the accepted offer.
|
||||||
|
static func validate(
|
||||||
|
payload: Data,
|
||||||
|
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||||
|
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
|
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||||
return .failure(.malformedPayload)
|
return .failure(.malformedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
|
var capabilities: PeerCapabilities = []
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
@@ -107,6 +108,10 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||||
|
peers[peerID.toShort()]?.capabilities ?? []
|
||||||
|
}
|
||||||
|
|
||||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
@@ -157,7 +162,8 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date
|
now: Date,
|
||||||
|
capabilities: PeerCapabilities = []
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
@@ -173,7 +179,8 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now,
|
||||||
|
capabilities: capabilities
|
||||||
)
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ final class BLEService: NSObject {
|
|||||||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||||||
// File-transfer orchestration (queue hops stay in the environment closures)
|
// File-transfer orchestration (queue hops stay in the environment closures)
|
||||||
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
||||||
|
// Wi-Fi bulk data plane: BLE/Noise negotiates, AWDL carries large media
|
||||||
|
private lazy var wifiBulkService = WifiBulkTransferService(environment: makeWifiBulkEnvironment())
|
||||||
|
|
||||||
// MARK: - Gossip Sync
|
// MARK: - Gossip Sync
|
||||||
private var gossipSyncManager: GossipSyncManager?
|
private var gossipSyncManager: GossipSyncManager?
|
||||||
@@ -269,6 +271,12 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Initialize gossip sync manager
|
// Initialize gossip sync manager
|
||||||
restartGossipManager()
|
restartGossipManager()
|
||||||
|
|
||||||
|
// Force single-threaded instantiation: unlike the packet handlers
|
||||||
|
// (touched only from messageQueue), the Wi-Fi bulk service is reached
|
||||||
|
// from the main actor too (cancelTransfer/stopServices), and lazy
|
||||||
|
// vars are not thread-safe.
|
||||||
|
_ = wifiBulkService
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartGossipManager() {
|
private func restartGossipManager() {
|
||||||
@@ -504,6 +512,9 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
|
// Tear down any Wi-Fi bulk listeners/connections deterministically
|
||||||
|
wifiBulkService.stop()
|
||||||
|
|
||||||
// Send leave message synchronously to ensure delivery
|
// Send leave message synchronously to ensure delivery
|
||||||
var leavePacket = BitchatPacket(
|
var leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
@@ -619,6 +630,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capabilities the peer advertised in its last verified announce.
|
||||||
|
/// Empty for peers that predate the capabilities TLV.
|
||||||
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities {
|
||||||
|
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||||
|
}
|
||||||
|
|
||||||
func getPeerNicknames() -> [PeerID: String] {
|
func getPeerNicknames() -> [PeerID: String] {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||||||
@@ -690,6 +707,9 @@ final class BLEService: NSObject {
|
|||||||
// MARK: Messaging
|
// MARK: Messaging
|
||||||
|
|
||||||
func cancelTransfer(_ transferId: String) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
|
// A transfer may be riding the Wi-Fi bulk channel instead of the BLE
|
||||||
|
// fragment scheduler; cancelling both is safe (each no-ops on miss).
|
||||||
|
wifiBulkService.cancelTransfer(transferId: transferId)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -765,10 +785,72 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
// Encode with the Wi-Fi bulk ceiling; whether the payload also
|
||||||
|
// fits the BLE caps decides what a fallback may do.
|
||||||
|
guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let fitsBLECaps = filePacket.encode() != nil
|
||||||
|
|
||||||
|
// 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
|
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
|
||||||
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
||||||
let targetID = peerID.toShort()
|
let targetID = peerID.toShort()
|
||||||
@@ -797,6 +879,13 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drives the progress bus through started→cancelled so the UI clears a
|
||||||
|
/// transfer that no transport can carry.
|
||||||
|
private func surfaceUndeliverableTransfer(_ transferId: String) {
|
||||||
|
TransferProgressManager.shared.start(id: transferId, totalFragments: 1)
|
||||||
|
TransferProgressManager.shared.cancel(id: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||||||
@@ -1182,6 +1271,57 @@ final class BLEService: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the Wi-Fi bulk service environment. Noise encryption and packet
|
||||||
|
/// dispatch stay on the message queue; incoming payloads re-enter the
|
||||||
|
/// normal file-transfer pipeline as if a fully assembled packet arrived.
|
||||||
|
private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment {
|
||||||
|
WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { [weak self] typedPayload, peerID in
|
||||||
|
guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false }
|
||||||
|
self.messageQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
do {
|
||||||
|
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { [weak self] peerID in
|
||||||
|
self?.isPeerConnected(peerID) ?? false
|
||||||
|
},
|
||||||
|
deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in
|
||||||
|
self?.messageQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
senderID: Data(hexString: peerID.toShort().id) ?? Data(),
|
||||||
|
recipientID: self.myPeerIDData,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 0,
|
||||||
|
version: 2
|
||||||
|
)
|
||||||
|
self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progressStart: { transferId, totalChunks in
|
||||||
|
TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks)
|
||||||
|
},
|
||||||
|
progressChunkSent: { transferId in
|
||||||
|
TransferProgressManager.shared.recordFragmentSent(id: transferId)
|
||||||
|
},
|
||||||
|
progressReset: { transferId in
|
||||||
|
TransferProgressManager.shared.reset(id: transferId)
|
||||||
|
},
|
||||||
|
progressCancel: { transferId in
|
||||||
|
TransferProgressManager.shared.cancel(id: transferId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||||||
|
|
||||||
@@ -1261,7 +1401,8 @@ final class BLEService: NSObject {
|
|||||||
nickname: myNickname,
|
nickname: myNickname,
|
||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub,
|
signingPublicKey: signingPub,
|
||||||
directNeighbors: connectedPeerIDs
|
directNeighbors: connectedPeerIDs,
|
||||||
|
capabilities: PeerCapabilities.localSupported
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = announcement.encode() else {
|
guard let payload = announcement.encode() else {
|
||||||
@@ -1715,6 +1856,34 @@ extension BLEService {
|
|||||||
cachedServiceUUIDs: cachedServiceUUIDs
|
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
|
#endif
|
||||||
|
|
||||||
@@ -3315,7 +3484,8 @@ extension BLEService {
|
|||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
now: now
|
now: now,
|
||||||
|
capabilities: announcement.capabilities ?? []
|
||||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
},
|
},
|
||||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||||
@@ -3502,8 +3672,21 @@ extension BLEService {
|
|||||||
self?.noiseService.clearSession(for: peerID)
|
self?.noiseService.clearSession(for: peerID)
|
||||||
},
|
},
|
||||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||||
|
guard let self = self else { return }
|
||||||
|
// Wi-Fi bulk negotiation is a transport concern; consume it
|
||||||
|
// here instead of surfacing it to the UI layer.
|
||||||
|
switch type {
|
||||||
|
case .bulkTransferOffer:
|
||||||
|
self.wifiBulkService.handleOfferPayload(payload, from: peerID)
|
||||||
|
return
|
||||||
|
case .bulkTransferResponse:
|
||||||
|
self.wifiBulkService.handleResponsePayload(payload, from: peerID)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
// Single main-actor hop delivering `.noisePayloadReceived`.
|
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||||||
self?.notifyUI { [weak self] in
|
self.notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
type: type,
|
type: type,
|
||||||
|
|||||||
@@ -281,6 +281,21 @@ enum TransportConfig {
|
|||||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||||
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
|
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
|
||||||
|
// BLE stays the control plane: offers/responses ride the Noise session,
|
||||||
|
// only the sealed chunk stream moves to TCP over AWDL.
|
||||||
|
static let wifiBulkEnabled: Bool = true
|
||||||
|
// Below this size BLE fragmentation is fast enough that negotiation
|
||||||
|
// overhead isn't worth it.
|
||||||
|
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
|
||||||
|
static let wifiBulkChunkBytes: Int = 64 * 1024
|
||||||
|
// Offer unanswered for this long → fall back to BLE fragmentation.
|
||||||
|
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
|
||||||
|
// Hard ceiling on how long the Bonjour listener/connection may live.
|
||||||
|
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
|
||||||
|
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
|
||||||
|
static let wifiBulkMaxConcurrentIncoming: Int = 4
|
||||||
|
|
||||||
// Courier store-and-forward
|
// Courier store-and-forward
|
||||||
// Initial spray-and-wait budget per deposited envelope: each courier may
|
// Initial spray-and-wait budget per deposited envelope: each courier may
|
||||||
// hand half its remaining copies to another courier on encounter, so a
|
// hand half its remaining copies to another courier on encounter, so a
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkChannel.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
|
||||||
|
/// the connection's dispatch queue.
|
||||||
|
enum WifiBulkStream {
|
||||||
|
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
|
||||||
|
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
|
||||||
|
chunkBytes + WifiBulkCrypto.frameOverhead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads frames until `onFrame` returns false (stop) or the stream
|
||||||
|
/// errors/closes. `onFrame` returning true keeps the loop alive.
|
||||||
|
static func readFrames(
|
||||||
|
on connection: NWConnection,
|
||||||
|
buffer: WifiBulkFrameBuffer,
|
||||||
|
maxFrameBodyBytes: Int,
|
||||||
|
onFrame: @escaping (Data) -> Bool,
|
||||||
|
onError: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
// Drain any frames already buffered before touching the socket.
|
||||||
|
do {
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
guard onFrame(body) else { return }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError("frame decode failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.receive(
|
||||||
|
minimumIncompleteLength: 1,
|
||||||
|
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
|
||||||
|
) { data, _, isComplete, error in
|
||||||
|
if let data, !data.isEmpty {
|
||||||
|
buffer.append(data)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
onError("receive failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
// Peer closed: hand over whatever complete frames remain, then
|
||||||
|
// report the close (sessions that already got what they need
|
||||||
|
// will have stopped the loop from inside onFrame).
|
||||||
|
do {
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
guard onFrame(body) else { return }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError("frame decode failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onError("connection closed by peer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: maxFrameBodyBytes,
|
||||||
|
onFrame: onFrame,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
|
||||||
|
/// listener, requires the first inbound frame to prove knowledge of the
|
||||||
|
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
|
||||||
|
/// receiver's verified receipt.
|
||||||
|
///
|
||||||
|
/// The listener starts at offer time (Bonjour registration takes a moment)
|
||||||
|
/// but data can only flow after `activate(key:)` supplies the channel key
|
||||||
|
/// derived from the accepted response.
|
||||||
|
final class WifiBulkSenderSession {
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let payload: Data
|
||||||
|
private let transferID: Data
|
||||||
|
private let payloadHash: Data
|
||||||
|
private let chunkBytes: Int
|
||||||
|
private let parameters: NWParameters
|
||||||
|
private let service: NWListener.Service?
|
||||||
|
private let maxCandidateConnections = 4
|
||||||
|
|
||||||
|
private var key: SymmetricKey?
|
||||||
|
private var listener: NWListener?
|
||||||
|
/// Connections that have not yet produced a valid auth frame.
|
||||||
|
private var candidates: [NWConnection] = []
|
||||||
|
private var authenticated: NWConnection?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
let totalChunks: Int
|
||||||
|
|
||||||
|
/// Test hook: fires once the listener is ready, with its bound port.
|
||||||
|
var onListenerReady: ((UInt16) -> Void)?
|
||||||
|
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
|
||||||
|
var onCompleted: (() -> Void)?
|
||||||
|
var onFailed: ((String) -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
payload: Data,
|
||||||
|
transferID: Data,
|
||||||
|
chunkBytes: Int,
|
||||||
|
parameters: NWParameters,
|
||||||
|
service: NWListener.Service?,
|
||||||
|
queue: DispatchQueue
|
||||||
|
) {
|
||||||
|
self.payload = payload
|
||||||
|
self.transferID = transferID
|
||||||
|
self.payloadHash = Data(SHA256.hash(data: payload))
|
||||||
|
self.chunkBytes = chunkBytes
|
||||||
|
self.parameters = parameters
|
||||||
|
self.service = service
|
||||||
|
self.queue = queue
|
||||||
|
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancelNetworkResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the listener. Returns false when the listener cannot be created
|
||||||
|
/// (caller falls back to BLE immediately).
|
||||||
|
func start() -> Bool {
|
||||||
|
let listener: NWListener
|
||||||
|
do {
|
||||||
|
listener = try NWListener(using: parameters)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("WifiBulk: listener creation failed: \(error)", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
listener.service = service
|
||||||
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if let port = listener.port?.rawValue {
|
||||||
|
self.onListenerReady?(port)
|
||||||
|
}
|
||||||
|
case .failed(let error):
|
||||||
|
self.fail("listener failed: \(error)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener.newConnectionHandler = { [weak self] connection in
|
||||||
|
self?.acceptCandidate(connection)
|
||||||
|
}
|
||||||
|
self.listener = listener
|
||||||
|
listener.start(queue: queue)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplies the channel key once the receiver accepted the offer; begins
|
||||||
|
/// authenticating any connections that raced ahead of the response.
|
||||||
|
func activate(key: SymmetricKey) {
|
||||||
|
guard !finished, self.key == nil else { return }
|
||||||
|
self.key = key
|
||||||
|
for candidate in candidates {
|
||||||
|
beginAuthentication(on: candidate, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
finished = true
|
||||||
|
cancelNetworkResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection handling
|
||||||
|
|
||||||
|
private func acceptCandidate(_ connection: NWConnection) {
|
||||||
|
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
|
||||||
|
connection.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates.append(connection)
|
||||||
|
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||||
|
guard let self, let connection else { return }
|
||||||
|
if case .failed = state {
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
if let key {
|
||||||
|
beginAuthentication(on: connection, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dropCandidate(_ connection: NWConnection) {
|
||||||
|
if let index = candidates.firstIndex(where: { $0 === connection }) {
|
||||||
|
candidates.remove(at: index)
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self, weak connection] body in
|
||||||
|
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
|
||||||
|
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
|
||||||
|
// Bonjour-level gatecrasher: no channel key, no service.
|
||||||
|
SecureLogger.warning("WifiBulk: disconnecting client with invalid auth frame", category: .security)
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError: { [weak self, weak connection] _ in
|
||||||
|
guard let self, let connection, self.authenticated !== connection else { return }
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
|
||||||
|
authenticated = connection
|
||||||
|
// One authenticated peer is all a transfer needs: stop advertising and
|
||||||
|
// shed the other candidates.
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
for candidate in candidates where candidate !== connection {
|
||||||
|
candidate.cancel()
|
||||||
|
}
|
||||||
|
candidates.removeAll()
|
||||||
|
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Streaming
|
||||||
|
|
||||||
|
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
|
||||||
|
guard !finished else { return }
|
||||||
|
guard index < totalChunks else {
|
||||||
|
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
|
||||||
|
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
|
||||||
|
let chunk = Data(payload[start..<end])
|
||||||
|
|
||||||
|
let body: Data
|
||||||
|
do {
|
||||||
|
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
|
||||||
|
} catch {
|
||||||
|
fail("chunk seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
if let error {
|
||||||
|
self.fail("send failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.onChunkSent?(index + 1, self.totalChunks)
|
||||||
|
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self] body in
|
||||||
|
guard let self, !self.finished else { return false }
|
||||||
|
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
|
||||||
|
self.fail("invalid receipt frame")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.finished = true
|
||||||
|
self.cancelNetworkResources()
|
||||||
|
self.onCompleted?()
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError: { [weak self] reason in
|
||||||
|
self?.fail("receipt wait failed: \(reason)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Teardown
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
cancelNetworkResources()
|
||||||
|
onFailed?(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelNetworkResources() {
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
authenticated?.cancel()
|
||||||
|
authenticated = nil
|
||||||
|
for candidate in candidates {
|
||||||
|
candidate.cancel()
|
||||||
|
}
|
||||||
|
candidates.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver side of the bulk channel: connects to the sender's per-transfer
|
||||||
|
/// endpoint, proves knowledge of the channel key with the first frame, then
|
||||||
|
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
|
||||||
|
final class WifiBulkReceiverSession {
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let connection: NWConnection
|
||||||
|
private let key: SymmetricKey
|
||||||
|
private let transferID: Data
|
||||||
|
private let payloadHash: Data
|
||||||
|
private let chunkBytes: Int
|
||||||
|
private let assembler: WifiBulkPayloadAssembler
|
||||||
|
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
var onCompleted: ((Data) -> Void)?
|
||||||
|
var onFailed: ((String) -> Void)?
|
||||||
|
|
||||||
|
/// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver
|
||||||
|
/// enforces the cap it advertised, not the sender's word.
|
||||||
|
init?(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
parameters: NWParameters,
|
||||||
|
key: SymmetricKey,
|
||||||
|
transferID: Data,
|
||||||
|
expectedSize: UInt64,
|
||||||
|
expectedHash: Data,
|
||||||
|
sizeCap: Int,
|
||||||
|
chunkBytes: Int,
|
||||||
|
queue: DispatchQueue
|
||||||
|
) {
|
||||||
|
guard let assembler = WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: expectedSize,
|
||||||
|
expectedHash: expectedHash,
|
||||||
|
sizeCap: sizeCap
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.assembler = assembler
|
||||||
|
self.connection = NWConnection(to: endpoint, using: parameters)
|
||||||
|
self.key = key
|
||||||
|
self.transferID = transferID
|
||||||
|
self.payloadHash = expectedHash
|
||||||
|
self.chunkBytes = chunkBytes
|
||||||
|
self.queue = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
self.sendAuthFrameAndReceive()
|
||||||
|
case .failed(let error):
|
||||||
|
self.fail("connect failed: \(error)")
|
||||||
|
case .waiting(let error):
|
||||||
|
// .waiting can resolve on its own, but a per-transfer channel
|
||||||
|
// has a peer actively listening; treat unreachable as fatal so
|
||||||
|
// the sender's fallback isn't left to the window timeout alone.
|
||||||
|
self.fail("connection waiting: \(error)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
finished = true
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAuthFrameAndReceive() {
|
||||||
|
guard !finished else { return }
|
||||||
|
let authBody: Data
|
||||||
|
do {
|
||||||
|
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||||
|
} catch {
|
||||||
|
fail("auth frame seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
if let error {
|
||||||
|
self.fail("auth frame send failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.receiveChunks()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveChunks() {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self] body in
|
||||||
|
guard let self, !self.finished else { return false }
|
||||||
|
do {
|
||||||
|
guard let payload = try self.assembler.consume(frameBody: body) else {
|
||||||
|
return true // keep reading
|
||||||
|
}
|
||||||
|
self.sendReceiptAndComplete(payload)
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
self.fail("chunk rejected: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: { [weak self] reason in
|
||||||
|
self?.fail(reason)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendReceiptAndComplete(_ payload: Data) {
|
||||||
|
let receiptBody: Data
|
||||||
|
do {
|
||||||
|
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
|
||||||
|
} catch {
|
||||||
|
fail("receipt seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
|
||||||
|
// Receipt is best-effort from the receiver's perspective: the
|
||||||
|
// payload is already verified. Close the channel either way.
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
self.finished = true
|
||||||
|
self.connection.cancel()
|
||||||
|
self.onCompleted?(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
connection.cancel()
|
||||||
|
onFailed?(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkCrypto.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Channel security for the Wi-Fi bulk data plane.
|
||||||
|
///
|
||||||
|
/// The TCP stream is encrypted and authenticated independently of TLS: both
|
||||||
|
/// endpoints exchanged random 32-byte tokens inside the established Noise
|
||||||
|
/// session, so only they can derive the ChaChaPoly channel key via
|
||||||
|
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
|
||||||
|
/// gatecrasher that connects to the listener cannot produce a single valid
|
||||||
|
/// frame and is disconnected.
|
||||||
|
///
|
||||||
|
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
|
||||||
|
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
|
||||||
|
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
|
||||||
|
/// and the reader requires the exact expected nonce for each frame, so frames
|
||||||
|
/// cannot be replayed, reordered, or reflected across directions.
|
||||||
|
enum WifiBulkCryptoError: Error, Equatable {
|
||||||
|
case invalidParameters
|
||||||
|
case frameTooLarge
|
||||||
|
case truncatedFrame
|
||||||
|
case nonceMismatch
|
||||||
|
case authenticationFailed
|
||||||
|
case emptyChunk
|
||||||
|
case payloadOverflow
|
||||||
|
case hashMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WifiBulkFrameDirection: UInt8 {
|
||||||
|
/// Data chunks: counters 0, 1, 2, …
|
||||||
|
case senderToReceiver = 0x00
|
||||||
|
/// Counter 0 = client auth frame, counter 1 = final receipt.
|
||||||
|
case receiverToSender = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WifiBulkCrypto {
|
||||||
|
static let keyDomain = "bitchat-bulk-v1"
|
||||||
|
static let nonceLength = 12
|
||||||
|
static let tagLength = 16
|
||||||
|
/// AEAD overhead per frame body (nonce + tag).
|
||||||
|
static let frameOverhead = nonceLength + tagLength
|
||||||
|
/// 4-byte big-endian length prefix per frame.
|
||||||
|
static let framePrefixLength = 4
|
||||||
|
|
||||||
|
// MARK: Key derivation
|
||||||
|
|
||||||
|
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
|
||||||
|
/// Deterministic: same tokens + transferID always yield the same key.
|
||||||
|
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
|
||||||
|
guard senderToken.count == WifiBulkWire.tokenLength,
|
||||||
|
receiverToken.count == WifiBulkWire.tokenLength,
|
||||||
|
transferID.count == WifiBulkWire.transferIDLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var inputKeyMaterial = Data()
|
||||||
|
inputKeyMaterial.append(senderToken)
|
||||||
|
inputKeyMaterial.append(receiverToken)
|
||||||
|
return HKDF<SHA256>.deriveKey(
|
||||||
|
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
|
||||||
|
salt: transferID,
|
||||||
|
info: Data(keyDomain.utf8),
|
||||||
|
outputByteCount: 32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame sealing
|
||||||
|
|
||||||
|
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
|
||||||
|
var nonce = Data(count: nonceLength)
|
||||||
|
nonce[0] = direction.rawValue
|
||||||
|
var counterBE = counter.bigEndian
|
||||||
|
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
|
||||||
|
static func sealFrameBody(
|
||||||
|
_ plaintext: Data,
|
||||||
|
direction: WifiBulkFrameDirection,
|
||||||
|
counter: UInt64,
|
||||||
|
key: SymmetricKey
|
||||||
|
) throws -> Data {
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
|
||||||
|
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens one frame body, enforcing the exact expected nonce.
|
||||||
|
static func openFrameBody(
|
||||||
|
_ body: Data,
|
||||||
|
direction: WifiBulkFrameDirection,
|
||||||
|
counter: UInt64,
|
||||||
|
key: SymmetricKey
|
||||||
|
) throws -> Data {
|
||||||
|
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
|
||||||
|
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
|
||||||
|
throw WifiBulkCryptoError.nonceMismatch
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let box = try ChaChaPoly.SealedBox(combined: body)
|
||||||
|
return try ChaChaPoly.open(box, using: key)
|
||||||
|
} catch {
|
||||||
|
throw WifiBulkCryptoError.authenticationFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
|
||||||
|
static func frameData(body: Data) -> Data {
|
||||||
|
var framed = Data(capacity: framePrefixLength + body.count)
|
||||||
|
var lengthBE = UInt32(body.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
|
||||||
|
framed.append(body)
|
||||||
|
return framed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Control frames
|
||||||
|
|
||||||
|
/// First frame on the wire, receiver → sender: proves the connecting
|
||||||
|
/// client holds the Noise-exchanged secret before any data flows.
|
||||||
|
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
|
||||||
|
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final frame, receiver → sender: acknowledges the fully verified payload.
|
||||||
|
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
|
||||||
|
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
|
||||||
|
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
|
||||||
|
final class WifiBulkFrameBuffer {
|
||||||
|
private var buffer = Data()
|
||||||
|
private let maxBodyBytes: Int
|
||||||
|
|
||||||
|
init(maxBodyBytes: Int) {
|
||||||
|
self.maxBodyBytes = maxBodyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ data: Data) {
|
||||||
|
buffer.append(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the next complete frame body, or nil when more bytes are needed.
|
||||||
|
func nextFrameBody() throws -> Data? {
|
||||||
|
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
|
||||||
|
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
|
||||||
|
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
|
||||||
|
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
|
||||||
|
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
|
||||||
|
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver-side reassembly: opens sequential data frames, enforces the size
|
||||||
|
/// negotiated in the accepted offer, and verifies the final SHA-256.
|
||||||
|
final class WifiBulkPayloadAssembler {
|
||||||
|
private let key: SymmetricKey
|
||||||
|
private let expectedSize: Int
|
||||||
|
private let expectedHash: Data
|
||||||
|
private var received = Data()
|
||||||
|
private var counter: UInt64 = 0
|
||||||
|
|
||||||
|
/// Fails when the offer exceeds the receiver-enforced cap.
|
||||||
|
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
|
||||||
|
guard expectedSize > 0,
|
||||||
|
expectedSize <= UInt64(sizeCap),
|
||||||
|
expectedHash.count == WifiBulkWire.hashLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.key = key
|
||||||
|
self.expectedSize = Int(expectedSize)
|
||||||
|
self.expectedHash = expectedHash
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool { received.count == expectedSize }
|
||||||
|
|
||||||
|
/// Consumes one sealed data frame body. Returns the verified payload when
|
||||||
|
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
|
||||||
|
func consume(frameBody: Data) throws -> Data? {
|
||||||
|
let chunk = try WifiBulkCrypto.openFrameBody(
|
||||||
|
frameBody,
|
||||||
|
direction: .senderToReceiver,
|
||||||
|
counter: counter,
|
||||||
|
key: key
|
||||||
|
)
|
||||||
|
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
|
||||||
|
counter += 1
|
||||||
|
guard received.count + chunk.count <= expectedSize else {
|
||||||
|
throw WifiBulkCryptoError.payloadOverflow
|
||||||
|
}
|
||||||
|
received.append(chunk)
|
||||||
|
guard isComplete else { return nil }
|
||||||
|
guard Data(SHA256.hash(data: received)) == expectedHash else {
|
||||||
|
throw WifiBulkCryptoError.hashMismatch
|
||||||
|
}
|
||||||
|
return received
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkMessages.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
|
||||||
|
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
|
||||||
|
///
|
||||||
|
/// Both messages ride the encrypted Noise channel, so every field — including
|
||||||
|
/// the session tokens and the random Bonjour instance name — is only visible
|
||||||
|
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
|
||||||
|
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
|
||||||
|
/// compatibility.
|
||||||
|
enum WifiBulkWire {
|
||||||
|
static let transferIDLength = 16
|
||||||
|
static let tokenLength = 32
|
||||||
|
static let hashLength = 32
|
||||||
|
/// Bonjour instance names are capped at 63 UTF-8 bytes.
|
||||||
|
static let maxServiceNameBytes = 63
|
||||||
|
|
||||||
|
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
|
||||||
|
data.append(type)
|
||||||
|
var length = UInt16(value.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
|
||||||
|
/// Returns false when the buffer is structurally malformed.
|
||||||
|
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
while cursor < end {
|
||||||
|
let type = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
guard data.distance(from: cursor, to: end) >= 2 else { return false }
|
||||||
|
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||||
|
cursor = data.index(cursor, offsetBy: 2)
|
||||||
|
guard data.distance(from: cursor, to: end) >= length else { return false }
|
||||||
|
let valueEnd = data.index(cursor, offsetBy: length)
|
||||||
|
visit(type, Data(data[cursor..<valueEnd]))
|
||||||
|
cursor = valueEnd
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender → receiver: proposal to move an already-encoded file payload over
|
||||||
|
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
|
||||||
|
struct WifiBulkOffer: Equatable {
|
||||||
|
/// Random per-transfer identifier; also the HKDF salt.
|
||||||
|
let transferID: Data
|
||||||
|
/// Exact byte count of the payload that will cross the channel.
|
||||||
|
let fileSize: UInt64
|
||||||
|
/// SHA-256 over the payload bytes as they cross the channel, verified by
|
||||||
|
/// the receiver after reassembly.
|
||||||
|
let payloadHash: Data
|
||||||
|
/// Sender's random half of the channel secret.
|
||||||
|
let token: Data
|
||||||
|
/// Random Bonjour instance name the sender publishes for this transfer.
|
||||||
|
/// Never derived from nickname or peer ID.
|
||||||
|
let serviceName: String
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case transferID = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case payloadHash = 0x03
|
||||||
|
case token = 0x04
|
||||||
|
case serviceName = 0x05
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard transferID.count == WifiBulkWire.transferIDLength,
|
||||||
|
payloadHash.count == WifiBulkWire.hashLength,
|
||||||
|
token.count == WifiBulkWire.tokenLength else { return nil }
|
||||||
|
let nameData = Data(serviceName.utf8)
|
||||||
|
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||||
|
var sizeBE = fileSize.bigEndian
|
||||||
|
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WifiBulkOffer? {
|
||||||
|
var transferID: Data?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var payloadHash: Data?
|
||||||
|
var token: Data?
|
||||||
|
var serviceName: String?
|
||||||
|
|
||||||
|
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||||
|
transferID = value
|
||||||
|
case .fileSize where value.count == 8:
|
||||||
|
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .payloadHash where value.count == WifiBulkWire.hashLength:
|
||||||
|
payloadHash = value
|
||||||
|
case .token where value.count == WifiBulkWire.tokenLength:
|
||||||
|
token = value
|
||||||
|
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
|
||||||
|
serviceName = String(data: value, encoding: .utf8)
|
||||||
|
default:
|
||||||
|
break // Unknown or malformed field: ignore; required checks below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard wellFormed,
|
||||||
|
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return WifiBulkOffer(
|
||||||
|
transferID: transferID,
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: payloadHash,
|
||||||
|
token: token,
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver → sender: accept (with the receiver's token half) or decline.
|
||||||
|
struct WifiBulkResponse: Equatable {
|
||||||
|
let transferID: Data
|
||||||
|
let accepted: Bool
|
||||||
|
/// Receiver's random half of the channel secret; present iff accepted.
|
||||||
|
let token: Data?
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case transferID = 0x01
|
||||||
|
case accepted = 0x02
|
||||||
|
case token = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
|
||||||
|
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decline(transferID: Data) -> WifiBulkResponse {
|
||||||
|
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
|
||||||
|
if accepted {
|
||||||
|
guard token?.count == WifiBulkWire.tokenLength else { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
|
||||||
|
if accepted, let token {
|
||||||
|
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WifiBulkResponse? {
|
||||||
|
var transferID: Data?
|
||||||
|
var accepted: Bool?
|
||||||
|
var token: Data?
|
||||||
|
|
||||||
|
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||||
|
transferID = value
|
||||||
|
case .accepted where value.count == 1:
|
||||||
|
accepted = value.first == 1
|
||||||
|
case .token where value.count == WifiBulkWire.tokenLength:
|
||||||
|
token = value
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard wellFormed, let transferID, let accepted else { return nil }
|
||||||
|
if accepted {
|
||||||
|
guard let token else { return nil }
|
||||||
|
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||||
|
}
|
||||||
|
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkPolicy.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
|
||||||
|
/// fails these gates rides BLE fragmentation exactly as before — the BLE
|
||||||
|
/// fallback is the common case and must stay bulletproof.
|
||||||
|
enum WifiBulkPolicy {
|
||||||
|
struct SendCandidate {
|
||||||
|
let payloadBytes: Int
|
||||||
|
let peerCapabilities: PeerCapabilities
|
||||||
|
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
|
||||||
|
/// only reaches direct neighbors, and relays can't proxy the channel.
|
||||||
|
let isDirectlyConnected: Bool
|
||||||
|
/// The offer rides the Noise session, so one must already exist.
|
||||||
|
let hasEstablishedNoiseSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldOffer(
|
||||||
|
_ candidate: SendCandidate,
|
||||||
|
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||||
|
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
|
||||||
|
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) -> Bool {
|
||||||
|
enabled
|
||||||
|
&& candidate.payloadBytes > minPayloadBytes
|
||||||
|
&& candidate.payloadBytes <= maxPayloadBytes
|
||||||
|
&& candidate.peerCapabilities.contains(.wifiBulk)
|
||||||
|
&& candidate.isDirectlyConnected
|
||||||
|
&& candidate.hasEstablishedNoiseSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver-side gate. Field lengths were validated at decode; this
|
||||||
|
/// enforces the size cap (from the local ceiling, not the sender's word)
|
||||||
|
/// and local enablement.
|
||||||
|
static func shouldAccept(
|
||||||
|
offer: WifiBulkOffer,
|
||||||
|
senderIsDirectlyConnected: Bool,
|
||||||
|
activeIncomingTransfers: Int,
|
||||||
|
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||||
|
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
|
||||||
|
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||||
|
) -> Bool {
|
||||||
|
enabled
|
||||||
|
&& senderIsDirectlyConnected
|
||||||
|
&& activeIncomingTransfers < maxConcurrentIncoming
|
||||||
|
&& offer.fileSize > 0
|
||||||
|
&& offer.fileSize <= UInt64(maxPayloadBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkTransferService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
|
||||||
|
/// hops live inside the closures supplied by `BLEService`, keeping this
|
||||||
|
/// service independently testable.
|
||||||
|
struct WifiBulkTransferServiceEnvironment {
|
||||||
|
/// Sends a typed payload inside the established Noise session with the
|
||||||
|
/// peer. Returns false when no established session exists (the caller
|
||||||
|
/// falls back to BLE).
|
||||||
|
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
|
||||||
|
/// Whether the peer is on a direct BLE link right now.
|
||||||
|
let isPeerConnected: (PeerID) -> Bool
|
||||||
|
/// Delivers a fully received, hash-verified payload (encoded
|
||||||
|
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
|
||||||
|
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
|
||||||
|
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
|
||||||
|
/// unchanged (chunks report as "fragments").
|
||||||
|
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
|
||||||
|
let progressChunkSent: (_ transferId: String) -> Void
|
||||||
|
/// Silently forgets progress state ahead of a BLE fallback re-start.
|
||||||
|
let progressReset: (_ transferId: String) -> Void
|
||||||
|
/// Emits the cancelled event for user-cancelled transfers.
|
||||||
|
let progressCancel: (_ transferId: String) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knobs with test overrides; production values come from `TransportConfig`.
|
||||||
|
struct WifiBulkTransferServiceConfig {
|
||||||
|
var serviceType: String = TransportConfig.wifiBulkServiceType
|
||||||
|
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
|
||||||
|
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
|
||||||
|
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
|
||||||
|
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||||
|
/// Tests disable peer-to-peer so loopback interfaces stay usable.
|
||||||
|
var usePeerToPeer: Bool = true
|
||||||
|
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
|
||||||
|
var publishBonjourService: Bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
|
||||||
|
/// response (control plane), then the payload crosses a per-transfer TCP
|
||||||
|
/// channel over AWDL, sealed with a key both sides derived from the
|
||||||
|
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
|
||||||
|
/// fragmentation exactly once; the receiver side fails silently and lets the
|
||||||
|
/// sender's timeout drive that fallback.
|
||||||
|
final class WifiBulkTransferService {
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
|
||||||
|
private let environment: WifiBulkTransferServiceEnvironment
|
||||||
|
private let config: WifiBulkTransferServiceConfig
|
||||||
|
|
||||||
|
private final class OutgoingTransfer {
|
||||||
|
let transferID: Data
|
||||||
|
let transferId: String
|
||||||
|
let peerID: PeerID
|
||||||
|
let token: Data
|
||||||
|
let fallback: () -> Void
|
||||||
|
var session: WifiBulkSenderSession?
|
||||||
|
var offerTimeout: DispatchWorkItem?
|
||||||
|
var windowTimeout: DispatchWorkItem?
|
||||||
|
var accepted = false
|
||||||
|
var finished = false
|
||||||
|
|
||||||
|
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
|
||||||
|
self.transferID = transferID
|
||||||
|
self.transferId = transferId
|
||||||
|
self.peerID = peerID
|
||||||
|
self.token = token
|
||||||
|
self.fallback = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class IncomingTransfer {
|
||||||
|
let offer: WifiBulkOffer
|
||||||
|
let peerID: PeerID
|
||||||
|
let key: SymmetricKey
|
||||||
|
var browser: NWBrowser?
|
||||||
|
var session: WifiBulkReceiverSession?
|
||||||
|
var windowTimeout: DispatchWorkItem?
|
||||||
|
|
||||||
|
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
|
||||||
|
self.offer = offer
|
||||||
|
self.peerID = peerID
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outgoing: [Data: OutgoingTransfer] = [:]
|
||||||
|
private var incoming: [Data: IncomingTransfer] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
environment: WifiBulkTransferServiceEnvironment,
|
||||||
|
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sender
|
||||||
|
|
||||||
|
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
|
||||||
|
/// most once, on decline, timeout, or any mid-transfer error.
|
||||||
|
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a decrypted `bulkTransferResponse` Noise payload.
|
||||||
|
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.processResponse(payload, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
|
||||||
|
func cancelTransfer(transferId: String) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tears down every transfer (service shutdown / emergency disconnect).
|
||||||
|
/// In-flight outgoing transfers do NOT fall back — the transport is going away.
|
||||||
|
func stop() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for transfer in self.outgoing.values {
|
||||||
|
transfer.finished = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.session?.cancel()
|
||||||
|
}
|
||||||
|
self.outgoing.removeAll()
|
||||||
|
for transfer in self.incoming.values {
|
||||||
|
self.tearDownIncomingResources(transfer)
|
||||||
|
}
|
||||||
|
self.incoming.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum OutgoingOutcome {
|
||||||
|
case completed
|
||||||
|
case fallback
|
||||||
|
case cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||||
|
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
|
||||||
|
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||||
|
// Random per-transfer instance name — never the nickname or peer ID.
|
||||||
|
let serviceName = Self.randomData(16).hexEncodedString()
|
||||||
|
|
||||||
|
let offer = WifiBulkOffer(
|
||||||
|
transferID: transferID,
|
||||||
|
fileSize: UInt64(payload.count),
|
||||||
|
payloadHash: Data(SHA256.hash(data: payload)),
|
||||||
|
token: token,
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
guard let offerData = offer.encode() else {
|
||||||
|
fallbackToBLE()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = OutgoingTransfer(
|
||||||
|
transferID: transferID,
|
||||||
|
transferId: transferId,
|
||||||
|
peerID: peerID,
|
||||||
|
token: token,
|
||||||
|
fallback: fallbackToBLE
|
||||||
|
)
|
||||||
|
|
||||||
|
let session = WifiBulkSenderSession(
|
||||||
|
payload: payload,
|
||||||
|
transferID: transferID,
|
||||||
|
chunkBytes: config.chunkBytes,
|
||||||
|
parameters: makeParameters(),
|
||||||
|
service: config.publishBonjourService
|
||||||
|
? NWListener.Service(name: serviceName, type: config.serviceType)
|
||||||
|
: nil,
|
||||||
|
queue: queue
|
||||||
|
)
|
||||||
|
if let onListenerReady = _test_onListenerReady {
|
||||||
|
session.onListenerReady = { port in onListenerReady(transferID, port) }
|
||||||
|
}
|
||||||
|
session.onChunkSent = { [weak self, weak transfer] sent, total in
|
||||||
|
guard let self, let transfer, !transfer.finished else { return }
|
||||||
|
// Hold the final tick until the receipt confirms delivery, so the
|
||||||
|
// progress bus only emits .completed for verified transfers.
|
||||||
|
if sent < total {
|
||||||
|
self.environment.progressChunkSent(transfer.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.onCompleted = { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer, !transfer.finished else { return }
|
||||||
|
self.environment.progressChunkSent(transfer.transferId)
|
||||||
|
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
|
||||||
|
}
|
||||||
|
session.onFailed = { [weak self, weak transfer] reason in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
|
||||||
|
}
|
||||||
|
transfer.session = session
|
||||||
|
outgoing[transferID] = transfer
|
||||||
|
|
||||||
|
guard session.start() else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
|
||||||
|
peerID
|
||||||
|
) else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .session)
|
||||||
|
environment.progressStart(transferId, session.totalChunks)
|
||||||
|
|
||||||
|
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer, !transfer.accepted else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
|
||||||
|
}
|
||||||
|
transfer.offerTimeout = offerTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
|
||||||
|
|
||||||
|
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
|
||||||
|
}
|
||||||
|
transfer.windowTimeout = windowTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processResponse(_ payload: Data, from peerID: PeerID) {
|
||||||
|
guard let response = WifiBulkResponse.decode(payload),
|
||||||
|
let transfer = outgoing[response.transferID],
|
||||||
|
transfer.peerID.toShort() == peerID.toShort(),
|
||||||
|
!transfer.accepted, !transfer.finished else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard response.accepted, let receiverToken = response.token else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let key = WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: transfer.token,
|
||||||
|
receiverToken: receiverToken,
|
||||||
|
transferID: transfer.transferID
|
||||||
|
) else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer.accepted = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.offerTimeout = nil
|
||||||
|
transfer.session?.activate(key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
|
||||||
|
guard !transfer.finished else { return }
|
||||||
|
transfer.finished = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.session?.cancel()
|
||||||
|
outgoing.removeValue(forKey: transfer.transferID)
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case .completed:
|
||||||
|
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session)
|
||||||
|
case .fallback:
|
||||||
|
SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8))… falling back to BLE (\(reason))", category: .session)
|
||||||
|
environment.progressReset(transfer.transferId)
|
||||||
|
transfer.fallback()
|
||||||
|
case .cancelled:
|
||||||
|
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session)
|
||||||
|
environment.progressCancel(transfer.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receiver
|
||||||
|
|
||||||
|
/// Handles a decrypted `bulkTransferOffer` Noise payload.
|
||||||
|
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.processOffer(payload, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processOffer(_ payload: Data, from peerID: PeerID) {
|
||||||
|
guard let offer = WifiBulkOffer.decode(payload) else { return }
|
||||||
|
guard incoming[offer.transferID] == nil else { return }
|
||||||
|
|
||||||
|
guard WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer,
|
||||||
|
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
|
||||||
|
activeIncomingTransfers: incoming.count,
|
||||||
|
maxPayloadBytes: config.maxIncomingPayloadBytes,
|
||||||
|
maxConcurrentIncoming: config.maxConcurrentIncoming
|
||||||
|
) else {
|
||||||
|
decline(offer: offer, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||||
|
guard let key = WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: offer.token,
|
||||||
|
receiverToken: token,
|
||||||
|
transferID: offer.transferID
|
||||||
|
),
|
||||||
|
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
|
||||||
|
decline(offer: offer, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||||
|
peerID
|
||||||
|
) else {
|
||||||
|
return // No session to answer on; the sender's timeout handles fallback.
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||||
|
incoming[offer.transferID] = transfer
|
||||||
|
SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
|
||||||
|
startBrowsing(for: transfer)
|
||||||
|
|
||||||
|
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
transfer.windowTimeout = windowTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||||
|
SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
|
||||||
|
_ = environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||||
|
peerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startBrowsing(for transfer: IncomingTransfer) {
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: config.serviceType, domain: nil),
|
||||||
|
using: makeParameters()
|
||||||
|
)
|
||||||
|
transfer.browser = browser
|
||||||
|
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
|
||||||
|
guard let self, let transfer, transfer.session == nil else { return }
|
||||||
|
let match = results.first { result in
|
||||||
|
if case .service(let name, _, _, _) = result.endpoint {
|
||||||
|
return name == transfer.offer.serviceName
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let match else { return }
|
||||||
|
self.connect(transfer, to: match.endpoint)
|
||||||
|
}
|
||||||
|
browser.stateUpdateHandler = { [weak self, weak transfer] state in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
if case .failed(let error) = state {
|
||||||
|
SecureLogger.warning("WifiBulk: browser failed: \(error)", category: .session)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test hook: connects an accepted incoming transfer straight to an
|
||||||
|
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
|
||||||
|
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
|
||||||
|
self.connect(transfer, to: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
|
||||||
|
transfer.browser?.cancel()
|
||||||
|
transfer.browser = nil
|
||||||
|
|
||||||
|
guard let session = WifiBulkReceiverSession(
|
||||||
|
endpoint: endpoint,
|
||||||
|
parameters: makeParameters(),
|
||||||
|
key: transfer.key,
|
||||||
|
transferID: transfer.offer.transferID,
|
||||||
|
expectedSize: transfer.offer.fileSize,
|
||||||
|
expectedHash: transfer.offer.payloadHash,
|
||||||
|
sizeCap: config.maxIncomingPayloadBytes,
|
||||||
|
chunkBytes: config.chunkBytes,
|
||||||
|
queue: queue
|
||||||
|
) else {
|
||||||
|
tearDownIncoming(transfer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.onCompleted = { [weak self, weak transfer] payload in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .session)
|
||||||
|
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
session.onFailed = { [weak self, weak transfer] reason in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
transfer.session = session
|
||||||
|
session.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownIncoming(_ transfer: IncomingTransfer) {
|
||||||
|
tearDownIncomingResources(transfer)
|
||||||
|
incoming.removeValue(forKey: transfer.offer.transferID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.windowTimeout = nil
|
||||||
|
transfer.browser?.cancel()
|
||||||
|
transfer.browser = nil
|
||||||
|
transfer.session?.cancel()
|
||||||
|
transfer.session = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeParameters() -> NWParameters {
|
||||||
|
let parameters = NWParameters.tcp
|
||||||
|
if config.usePeerToPeer {
|
||||||
|
parameters.includePeerToPeer = true
|
||||||
|
// Keep the channel off infrastructure-independent radios we never
|
||||||
|
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
|
||||||
|
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
|
||||||
|
}
|
||||||
|
return parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
|
||||||
|
private static func randomData(_ count: Int) -> Data {
|
||||||
|
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test observability
|
||||||
|
|
||||||
|
/// Test hook: reports each outgoing listener's bound port, standing in
|
||||||
|
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
|
||||||
|
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
|
||||||
|
|
||||||
|
var _test_activeOutgoingCount: Int {
|
||||||
|
queue.sync { outgoing.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
var _test_activeIncomingCount: Int {
|
||||||
|
queue.sync { incoming.count }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -371,6 +371,11 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
|
case .bulkTransferOffer, .bulkTransferResponse:
|
||||||
|
// Wi-Fi bulk negotiation is consumed inside the mesh transport
|
||||||
|
// (BLEService); it never reaches the UI layer.
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .verifyChallenge, .verifyResponse:
|
case .verifyChallenge, .verifyResponse,
|
||||||
|
.bulkTransferOffer, .bulkTransferResponse:
|
||||||
|
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +351,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .verifyChallenge, .verifyResponse:
|
case .verifyChallenge, .verifyResponse,
|
||||||
|
.bulkTransferOffer, .bulkTransferResponse:
|
||||||
|
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,7 +432,10 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
case .verifyChallenge, .verifyResponse:
|
case .verifyChallenge, .verifyResponse,
|
||||||
|
.bulkTransferOffer, .bulkTransferResponse:
|
||||||
|
// Wi-Fi bulk negotiation is mesh-proximity only;
|
||||||
|
// it never rides Nostr.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,51 @@ struct BLEServiceCoreTests {
|
|||||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
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 {
|
private func makeService() -> BLEService {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
import Testing
|
import Testing
|
||||||
|
import UniformTypeIdentifiers
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
@@ -58,6 +61,25 @@ struct ChatMediaPreparationTests {
|
|||||||
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
|
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
|
||||||
#expect(prepared.packet.encode() != nil)
|
#expect(prepared.packet.encode() != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A genuinely detailed photo must prepare to more than
|
||||||
|
/// `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB); otherwise the Wi-Fi
|
||||||
|
/// bulk (AWDL) data plane is never offered in production because
|
||||||
|
/// `WifiBulkPolicy.shouldOffer` requires `payloadBytes > 64 KiB`.
|
||||||
|
/// Regression guard for the ~40 KB over-compression gap (PR #1385).
|
||||||
|
@Test
|
||||||
|
func prepareImagePacket_detailedImageExceedsWifiBulkThreshold() throws {
|
||||||
|
let sourceURL = try makeDetailedImageURL(dimension: 1200)
|
||||||
|
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||||
|
|
||||||
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||||
|
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
|
||||||
|
|
||||||
|
// Comfortably above the 64 KiB Wi-Fi bulk offer threshold...
|
||||||
|
#expect(prepared.packet.content.count > TransportConfig.wifiBulkMinPayloadBytes)
|
||||||
|
// ...and still within the hard image cap for the BLE path.
|
||||||
|
#expect(prepared.packet.content.count <= FileTransferLimits.maxImageBytes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeTemporaryImageURL() throws -> URL {
|
private func makeTemporaryImageURL() throws -> URL {
|
||||||
@@ -87,6 +109,50 @@ private func makeTemporaryImageURL() throws -> URL {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a random-noise PNG. Noise is incompressible, so the JPEG the prep
|
||||||
|
/// pipeline produces stays large — a faithful stand-in for a detailed photo,
|
||||||
|
/// unlike a flat solid-color image which would compress to a few KB regardless
|
||||||
|
/// of dimension.
|
||||||
|
private func makeDetailedImageURL(dimension: Int) throws -> URL {
|
||||||
|
let width = dimension
|
||||||
|
let height = dimension
|
||||||
|
let bytesPerPixel = 4
|
||||||
|
let bytesPerRow = width * bytesPerPixel
|
||||||
|
|
||||||
|
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
|
||||||
|
for index in pixels.indices {
|
||||||
|
pixels[index] = UInt8.random(in: 0...255)
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: &pixels,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: bytesPerRow,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
), let cgImage = context.makeImage() else {
|
||||||
|
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("detailed-\(UUID().uuidString).png")
|
||||||
|
guard let destination = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL,
|
||||||
|
UTType.png.identifier as CFString,
|
||||||
|
1,
|
||||||
|
nil
|
||||||
|
) else {
|
||||||
|
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||||
|
}
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, nil)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
private enum ChatMediaPreparationTestError: Error {
|
private enum ChatMediaPreparationTestError: Error {
|
||||||
case imageEncodingFailed
|
case imageEncodingFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -108,6 +109,42 @@ struct PacketsTests {
|
|||||||
#expect(decoded.directNeighbors == nil)
|
#expect(decoded.directNeighbors == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func announcementPacketRoundTripsCapabilities() throws {
|
||||||
|
let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics]
|
||||||
|
let packet = AnnouncementPacket(
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||||
|
directNeighbors: nil,
|
||||||
|
capabilities: capabilities
|
||||||
|
)
|
||||||
|
|
||||||
|
let encoded = try #require(packet.encode())
|
||||||
|
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||||
|
#expect(decoded.capabilities == capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws {
|
||||||
|
let legacy = try #require(
|
||||||
|
AnnouncementPacket(
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||||
|
directNeighbors: nil
|
||||||
|
).encode()
|
||||||
|
)
|
||||||
|
// The TLV is emitted only when capabilities are set, so legacy peers
|
||||||
|
// (and this packet) decode as nil rather than empty.
|
||||||
|
#expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil)
|
||||||
|
|
||||||
|
var withFutureBits = legacy
|
||||||
|
withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01])))
|
||||||
|
let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits))
|
||||||
|
#expect(decoded.capabilities?.rawValue == 0x0180)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkCryptoTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("WifiBulk channel crypto")
|
||||||
|
struct WifiBulkCryptoTests {
|
||||||
|
private static func keyHex(_ key: SymmetricKey) -> String {
|
||||||
|
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeKey() throws -> SymmetricKey {
|
||||||
|
try #require(WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: Data(repeating: 0x11, count: 32),
|
||||||
|
receiverToken: Data(repeating: 0x22, count: 32),
|
||||||
|
transferID: Data(repeating: 0x33, count: 16)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: HKDF derivation
|
||||||
|
|
||||||
|
@Test("HKDF derivation matches fixed vectors")
|
||||||
|
func hkdfVectors() throws {
|
||||||
|
// Vectors computed independently with CryptoKit HKDF<SHA256>,
|
||||||
|
// ikm = senderToken ‖ receiverToken, salt = transferID,
|
||||||
|
// info = "bitchat-bulk-v1", 32 bytes out.
|
||||||
|
let key1 = try makeKey()
|
||||||
|
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
|
||||||
|
|
||||||
|
let key2 = try #require(WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: Data((0..<32).map { UInt8($0) }),
|
||||||
|
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
|
||||||
|
transferID: Data((0..<16).map { UInt8($0 * 3) })
|
||||||
|
))
|
||||||
|
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("HKDF derivation is order- and role-sensitive")
|
||||||
|
func hkdfRoleSensitivity() throws {
|
||||||
|
let a = Data(repeating: 0x11, count: 32)
|
||||||
|
let b = Data(repeating: 0x22, count: 32)
|
||||||
|
let tid = Data(repeating: 0x33, count: 16)
|
||||||
|
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
|
||||||
|
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
|
||||||
|
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("HKDF derivation rejects wrong-length inputs")
|
||||||
|
func hkdfRejectsBadLengths() {
|
||||||
|
let token = Data(repeating: 1, count: 32)
|
||||||
|
let tid = Data(repeating: 2, count: 16)
|
||||||
|
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
|
||||||
|
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
|
||||||
|
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame sealing
|
||||||
|
|
||||||
|
@Test("Frame seal/open round-trips")
|
||||||
|
func frameRoundTrip() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
|
||||||
|
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
|
||||||
|
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
|
||||||
|
#expect(opened == plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tampered frames are rejected")
|
||||||
|
func tamperRejection() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
body[body.count - 1] ^= 0x01 // flip a tag bit
|
||||||
|
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frames cannot be replayed at another counter or reflected across directions")
|
||||||
|
func nonceBinding() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
|
||||||
|
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
|
||||||
|
}
|
||||||
|
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frames sealed under a different key are rejected")
|
||||||
|
func wrongKeyRejection() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let otherKey = try #require(WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: Data(repeating: 0x44, count: 32),
|
||||||
|
receiverToken: Data(repeating: 0x22, count: 32),
|
||||||
|
transferID: Data(repeating: 0x33, count: 16)
|
||||||
|
))
|
||||||
|
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
|
||||||
|
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Auth and receipt control frames validate and reject forgeries")
|
||||||
|
func controlFrames() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let transferID = Data(repeating: 0x33, count: 16)
|
||||||
|
let hash = Data(repeating: 0x55, count: 32)
|
||||||
|
|
||||||
|
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||||
|
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
|
||||||
|
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
|
||||||
|
var forgedAuth = auth
|
||||||
|
forgedAuth[forgedAuth.count - 1] ^= 0x01
|
||||||
|
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
|
||||||
|
|
||||||
|
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
|
||||||
|
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
|
||||||
|
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
|
||||||
|
// An auth frame is not a receipt (distinct counter).
|
||||||
|
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame buffer
|
||||||
|
|
||||||
|
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
|
||||||
|
func frameBufferReassembly() throws {
|
||||||
|
let bodyA = Data(repeating: 0xAA, count: 100)
|
||||||
|
let bodyB = Data(repeating: 0xBB, count: 5)
|
||||||
|
var stream = WifiBulkCrypto.frameData(body: bodyA)
|
||||||
|
stream.append(WifiBulkCrypto.frameData(body: bodyB))
|
||||||
|
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
|
||||||
|
// Drip-feed 3 bytes at a time.
|
||||||
|
var extracted: [Data] = []
|
||||||
|
var index = stream.startIndex
|
||||||
|
while index < stream.endIndex {
|
||||||
|
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
|
||||||
|
buffer.append(Data(stream[index..<next]))
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
extracted.append(body)
|
||||||
|
}
|
||||||
|
index = next
|
||||||
|
}
|
||||||
|
#expect(extracted == [bodyA, bodyB])
|
||||||
|
#expect(try buffer.nextFrameBody() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frame buffer rejects oversized frame lengths without buffering them")
|
||||||
|
func frameBufferOversizeRejection() {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
|
||||||
|
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
|
||||||
|
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
|
||||||
|
_ = try buffer.nextFrameBody()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Payload assembler
|
||||||
|
|
||||||
|
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
|
||||||
|
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
|
||||||
|
try WifiBulkCrypto.sealFrameBody(
|
||||||
|
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
|
||||||
|
direction: .senderToReceiver,
|
||||||
|
counter: UInt64(index),
|
||||||
|
key: key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler reassembles and verifies a chunked payload")
|
||||||
|
func assemblerHappyPath() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(payload.count),
|
||||||
|
expectedHash: Data(SHA256.hash(data: payload)),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
|
||||||
|
var result: Data?
|
||||||
|
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
|
||||||
|
result = try assembler.consume(frameBody: chunk)
|
||||||
|
}
|
||||||
|
#expect(result == payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler rejects a payload whose final hash mismatches the offer")
|
||||||
|
func assemblerHashMismatch() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data(repeating: 0x77, count: 100_000)
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(payload.count),
|
||||||
|
expectedHash: Data(repeating: 0, count: 32), // wrong hash
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
|
||||||
|
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
|
||||||
|
_ = try assembler.consume(frameBody: chunks[0])
|
||||||
|
#expect(throws: WifiBulkCryptoError.hashMismatch) {
|
||||||
|
_ = try assembler.consume(frameBody: chunks[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler rejects overflow beyond the offered size")
|
||||||
|
func assemblerOverflow() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data(repeating: 0x77, count: 1000)
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: 500, // offer promised less than the sender streams
|
||||||
|
expectedHash: Data(SHA256.hash(data: payload)),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
|
||||||
|
_ = try assembler.consume(frameBody: chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler enforces the receiver-side size cap at construction")
|
||||||
|
func assemblerSizeCap() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
#expect(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
|
||||||
|
expectedHash: Data(repeating: 0, count: 32),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) == nil)
|
||||||
|
#expect(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: 0,
|
||||||
|
expectedHash: Data(repeating: 0, count: 32),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler rejects out-of-order chunks")
|
||||||
|
func assemblerOutOfOrder() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data(repeating: 0x42, count: 100_000)
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(payload.count),
|
||||||
|
expectedHash: Data(SHA256.hash(data: payload)),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
|
||||||
|
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||||
|
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkLoopbackTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// End-to-end integration over real Network.framework sockets on localhost:
|
||||||
|
/// two `WifiBulkTransferService` instances negotiate through an in-process
|
||||||
|
/// "Noise" pipe, then move the payload over a genuine TCP connection using
|
||||||
|
/// the production listener/browser-free test hooks (peer-to-peer and Bonjour
|
||||||
|
/// are disabled — unit-test hosts have no AWDL or mDNS access).
|
||||||
|
@Suite("WifiBulk loopback integration", .serialized)
|
||||||
|
struct WifiBulkLoopbackTests {
|
||||||
|
private static let senderPeer = PeerID(str: "00112233aabbccdd")
|
||||||
|
private static let receiverPeer = PeerID(str: "ddccbbaa33221100")
|
||||||
|
|
||||||
|
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||||
|
var config = WifiBulkTransferServiceConfig()
|
||||||
|
config.usePeerToPeer = false
|
||||||
|
config.publishBonjourService = false
|
||||||
|
config.offerTimeout = 5.0
|
||||||
|
config.transferWindow = 10.0
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Payload crosses a real TCP loopback channel and lands verified")
|
||||||
|
func loopbackTransferSucceeds() async throws {
|
||||||
|
let payload = Data((0..<300_000).map { UInt8($0 % 249) })
|
||||||
|
|
||||||
|
let delivered = WifiBulkTestBox<Data>()
|
||||||
|
let progress = WifiBulkTestBox<String>()
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||||
|
let offers = WifiBulkTestBox<Data>()
|
||||||
|
|
||||||
|
var senderService: WifiBulkTransferService?
|
||||||
|
var receiverService: WifiBulkTransferService?
|
||||||
|
|
||||||
|
// Once the receiver has accepted AND the listener port is known,
|
||||||
|
// connect the receiver straight to 127.0.0.1 (Bonjour stand-in).
|
||||||
|
let accepted = WifiBulkTestBox<Data>()
|
||||||
|
let connectIfReady: () -> Void = {
|
||||||
|
guard let (transferID, port) = ports.values.first,
|
||||||
|
accepted.values.contains(transferID),
|
||||||
|
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||||
|
receiverService?._test_connectIncoming(
|
||||||
|
transferID: transferID,
|
||||||
|
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
// Sender → receiver control plane (offer).
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||||
|
offers.append(Data(typed.dropFirst()))
|
||||||
|
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { _, _, _ in },
|
||||||
|
progressStart: { id, total in progress.append("start:\(id):\(total)") },
|
||||||
|
progressChunkSent: { id in progress.append("chunk:\(id)") },
|
||||||
|
progressReset: { id in progress.append("reset:\(id)") },
|
||||||
|
progressCancel: { id in progress.append("cancel:\(id)") }
|
||||||
|
)
|
||||||
|
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
// Receiver → sender control plane (response).
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||||
|
let body = Data(typed.dropFirst())
|
||||||
|
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||||
|
accepted.append(response.transferID)
|
||||||
|
}
|
||||||
|
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||||
|
connectIfReady()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { data, peer, limit in
|
||||||
|
#expect(peer == Self.senderPeer)
|
||||||
|
#expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes)
|
||||||
|
delivered.append(data)
|
||||||
|
},
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||||
|
sender._test_onListenerReady = { transferID, port in
|
||||||
|
ports.append((transferID, port))
|
||||||
|
connectIfReady()
|
||||||
|
}
|
||||||
|
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||||
|
senderService = sender
|
||||||
|
receiverService = receiver
|
||||||
|
|
||||||
|
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||||
|
#expect(delivered.values.first == payload)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
|
||||||
|
// Deterministic teardown: both sides drop all transfer state.
|
||||||
|
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||||
|
#expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 })
|
||||||
|
|
||||||
|
// Progress mirrored the BLE contract: start with the chunk total,
|
||||||
|
// then exactly `total` chunk ticks (the last one gated on the receipt).
|
||||||
|
let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes
|
||||||
|
#expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks })
|
||||||
|
#expect(progress.values.first == "start:t-loopback:\(totalChunks)")
|
||||||
|
#expect(!progress.values.contains("reset:t-loopback"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds")
|
||||||
|
func gatecrasherIsRejected() async throws {
|
||||||
|
let payload = Data((0..<150_000).map { UInt8($0 % 241) })
|
||||||
|
|
||||||
|
let delivered = WifiBulkTestBox<Data>()
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||||
|
let accepted = WifiBulkTestBox<Data>()
|
||||||
|
|
||||||
|
var senderService: WifiBulkTransferService?
|
||||||
|
var receiverService: WifiBulkTransferService?
|
||||||
|
|
||||||
|
let gatecrashed = WifiBulkTestBox<Bool>()
|
||||||
|
let connectIfReady: () -> Void = {
|
||||||
|
guard let (transferID, port) = ports.values.first,
|
||||||
|
accepted.values.contains(transferID),
|
||||||
|
gatecrashed.count > 0,
|
||||||
|
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||||
|
receiverService?._test_connectIncoming(
|
||||||
|
transferID: transferID,
|
||||||
|
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||||
|
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { _, _, _ in },
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||||
|
let body = Data(typed.dropFirst())
|
||||||
|
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||||
|
accepted.append(response.transferID)
|
||||||
|
}
|
||||||
|
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||||
|
connectIfReady()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { data, _, _ in delivered.append(data) },
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||||
|
let gateQueue = DispatchQueue(label: "test.gatecrasher")
|
||||||
|
sender._test_onListenerReady = { transferID, port in
|
||||||
|
ports.append((transferID, port))
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||||
|
// A stranger who saw the Bonjour advertisement connects first and
|
||||||
|
// sends garbage that cannot carry a valid MAC.
|
||||||
|
let crasher = NWConnection(
|
||||||
|
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort),
|
||||||
|
using: .tcp
|
||||||
|
)
|
||||||
|
crasher.stateUpdateHandler = { state in
|
||||||
|
if case .ready = state {
|
||||||
|
let junkBody = Data(repeating: 0xAA, count: 60)
|
||||||
|
crasher.send(
|
||||||
|
content: WifiBulkCrypto.frameData(body: junkBody),
|
||||||
|
completion: .contentProcessed { _ in
|
||||||
|
gatecrashed.append(true)
|
||||||
|
connectIfReady()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crasher.start(queue: gateQueue)
|
||||||
|
}
|
||||||
|
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||||
|
senderService = sender
|
||||||
|
receiverService = receiver
|
||||||
|
|
||||||
|
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||||
|
#expect(delivered.values.first == payload)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE")
|
||||||
|
func vanishedReceiverFallsBack() async {
|
||||||
|
var config = makeConfig()
|
||||||
|
config.offerTimeout = 0.3
|
||||||
|
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let environment = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { _, _, _ in },
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
let sender = WifiBulkTransferService(environment: environment, config: config)
|
||||||
|
sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
#expect(sender._test_activeOutgoingCount == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkMessagesTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("WifiBulk offer/response TLV")
|
||||||
|
struct WifiBulkMessagesTests {
|
||||||
|
private func makeOffer(
|
||||||
|
fileSize: UInt64 = 300_000,
|
||||||
|
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
|
||||||
|
) -> WifiBulkOffer {
|
||||||
|
WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
|
||||||
|
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer round-trips through TLV encoding")
|
||||||
|
func offerRoundTrip() throws {
|
||||||
|
let offer = makeOffer()
|
||||||
|
let encoded = try #require(offer.encode())
|
||||||
|
let decoded = try #require(WifiBulkOffer.decode(encoded))
|
||||||
|
#expect(decoded == offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer encode rejects malformed field lengths")
|
||||||
|
func offerEncodeRejectsBadFields() {
|
||||||
|
#expect(WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 1, count: 15), // short transferID
|
||||||
|
fileSize: 1,
|
||||||
|
payloadHash: Data(repeating: 2, count: 32),
|
||||||
|
token: Data(repeating: 3, count: 32),
|
||||||
|
serviceName: "x"
|
||||||
|
).encode() == nil)
|
||||||
|
|
||||||
|
#expect(makeOffer(serviceName: "").encode() == nil)
|
||||||
|
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer decode rejects missing or wrong-length fields")
|
||||||
|
func offerDecodeRejectsMalformed() throws {
|
||||||
|
let encoded = try #require(makeOffer().encode())
|
||||||
|
|
||||||
|
// Truncation anywhere breaks a TLV boundary or drops a required field.
|
||||||
|
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
|
||||||
|
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
|
||||||
|
#expect(WifiBulkOffer.decode(Data()) == nil)
|
||||||
|
|
||||||
|
// A wrong-length transferID TLV is ignored, leaving the field missing.
|
||||||
|
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
|
||||||
|
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
|
||||||
|
#expect(WifiBulkOffer.decode(mangled) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer decode skips unknown TLVs for forward compatibility")
|
||||||
|
func offerDecodeSkipsUnknownTLVs() throws {
|
||||||
|
var encoded = try #require(makeOffer().encode())
|
||||||
|
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
|
||||||
|
let decoded = try #require(WifiBulkOffer.decode(encoded))
|
||||||
|
#expect(decoded == makeOffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Accept response round-trips with token")
|
||||||
|
func acceptResponseRoundTrip() throws {
|
||||||
|
let response = WifiBulkResponse.accept(
|
||||||
|
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
|
||||||
|
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
|
||||||
|
)
|
||||||
|
let encoded = try #require(response.encode())
|
||||||
|
let decoded = try #require(WifiBulkResponse.decode(encoded))
|
||||||
|
#expect(decoded == response)
|
||||||
|
#expect(decoded.accepted)
|
||||||
|
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decline response round-trips without token")
|
||||||
|
func declineResponseRoundTrip() throws {
|
||||||
|
let response = WifiBulkResponse.decline(
|
||||||
|
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
|
||||||
|
)
|
||||||
|
let encoded = try #require(response.encode())
|
||||||
|
let decoded = try #require(WifiBulkResponse.decode(encoded))
|
||||||
|
#expect(decoded == response)
|
||||||
|
#expect(!decoded.accepted)
|
||||||
|
#expect(decoded.token == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Accept response without a token is rejected")
|
||||||
|
func acceptWithoutTokenRejected() throws {
|
||||||
|
// Hand-build: transferID + accepted=1, no token TLV.
|
||||||
|
var data = Data()
|
||||||
|
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
|
||||||
|
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
|
||||||
|
#expect(WifiBulkResponse.decode(data) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkPolicyTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("WifiBulk policy")
|
||||||
|
struct WifiBulkPolicyTests {
|
||||||
|
private func candidate(
|
||||||
|
payloadBytes: Int = 300_000,
|
||||||
|
capabilities: PeerCapabilities = [.wifiBulk],
|
||||||
|
direct: Bool = true,
|
||||||
|
session: Bool = true
|
||||||
|
) -> WifiBulkPolicy.SendCandidate {
|
||||||
|
WifiBulkPolicy.SendCandidate(
|
||||||
|
payloadBytes: payloadBytes,
|
||||||
|
peerCapabilities: capabilities,
|
||||||
|
isDirectlyConnected: direct,
|
||||||
|
hasEstablishedNoiseSession: session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
|
||||||
|
func eligibleTransferOffered() {
|
||||||
|
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
|
||||||
|
func fallbackMatrix() {
|
||||||
|
// Feature disabled.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
|
||||||
|
// Small file: negotiation overhead not worth it.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
|
||||||
|
// Peer doesn't advertise the capability.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
|
||||||
|
// Multi-hop recipient (reachable but not directly connected).
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
|
||||||
|
// No established Noise session to carry the offer.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
|
||||||
|
// Payload beyond even the Wi-Fi ceiling.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(
|
||||||
|
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer threshold is strictly greater than the minimum")
|
||||||
|
func offerThresholdBoundary() {
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(
|
||||||
|
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(WifiBulkPolicy.shouldOffer(
|
||||||
|
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func offer(fileSize: UInt64) -> WifiBulkOffer {
|
||||||
|
WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
|
||||||
|
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
|
||||||
|
serviceName: "0011223344556677"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver accepts an in-cap offer from a direct peer")
|
||||||
|
func receiverAccepts() {
|
||||||
|
#expect(WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1_000_000),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver enforces its own size cap, not the sender's word")
|
||||||
|
func receiverSizeCap() {
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 0),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver declines when disabled, indirect, or saturated")
|
||||||
|
func receiverDeclines() {
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1000),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: false
|
||||||
|
))
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1000),
|
||||||
|
senderIsDirectlyConnected: false,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1000),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("This build advertises the wifiBulk capability when enabled")
|
||||||
|
func localCapabilityAdvertised() {
|
||||||
|
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
|
||||||
|
func filePacketWifiLimit() throws {
|
||||||
|
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
|
||||||
|
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
|
||||||
|
|
||||||
|
// BLE cap unchanged.
|
||||||
|
#expect(packet.encode() == nil)
|
||||||
|
|
||||||
|
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||||
|
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
|
||||||
|
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||||
|
#expect(decoded.content == content)
|
||||||
|
#expect(decoded.fileName == "big.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkTransferServiceTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// Thread-safe capture box for closures invoked on service queues.
|
||||||
|
final class WifiBulkTestBox<Value>: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var storage: [Value] = []
|
||||||
|
|
||||||
|
func append(_ value: Value) {
|
||||||
|
lock.lock()
|
||||||
|
storage.append(value)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var values: [Value] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int { values.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
func wifiBulkWait(
|
||||||
|
timeout: TimeInterval = 5.0,
|
||||||
|
_ condition: @escaping () -> Bool
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if condition() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
}
|
||||||
|
return condition()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Negotiation/fallback decisions exercised through the real service with the
|
||||||
|
/// network kept on loopback and Bonjour publication disabled.
|
||||||
|
@Suite("WifiBulk transfer service negotiation", .serialized)
|
||||||
|
struct WifiBulkTransferServiceTests {
|
||||||
|
private static let peer = PeerID(str: "aabbccddeeff0011")
|
||||||
|
|
||||||
|
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||||
|
var config = WifiBulkTransferServiceConfig()
|
||||||
|
config.usePeerToPeer = false
|
||||||
|
config.publishBonjourService = false
|
||||||
|
config.offerTimeout = 0.25
|
||||||
|
config.transferWindow = 2.0
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEnvironment(
|
||||||
|
sendNoisePayload: @escaping (Data, PeerID) -> Bool,
|
||||||
|
deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in },
|
||||||
|
progressEvents: WifiBulkTestBox<String>? = nil
|
||||||
|
) -> WifiBulkTransferServiceEnvironment {
|
||||||
|
WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: sendNoisePayload,
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: deliver,
|
||||||
|
progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") },
|
||||||
|
progressChunkSent: { id in progressEvents?.append("chunk:\(id)") },
|
||||||
|
progressReset: { id in progressEvents?.append("reset:\(id)") },
|
||||||
|
progressCancel: { id in progressEvents?.append("cancel:\(id)") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var payload: Data { Data(repeating: 0x42, count: 100_000) }
|
||||||
|
|
||||||
|
@Test("No established Noise session falls straight back to BLE")
|
||||||
|
func noSessionFallsBack() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in false }),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
#expect(service._test_activeOutgoingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unanswered offer times out and falls back exactly once")
|
||||||
|
func offerTimeoutFallsBackOnce() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let progress = WifiBulkTestBox<String>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
// Wait past the transfer window: the expiry must not double-fire.
|
||||||
|
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||||
|
#expect(fallbacks.count == 1)
|
||||||
|
#expect(service._test_activeOutgoingCount == 0)
|
||||||
|
// Progress state was silently reset ahead of the BLE re-start.
|
||||||
|
#expect(progress.values.contains("reset:t-timeout"))
|
||||||
|
#expect(!progress.values.contains("cancel:t-timeout"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Declined offer falls back exactly once, even on duplicate declines")
|
||||||
|
func declineFallsBackOnce() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let offers = WifiBulkTestBox<Data>()
|
||||||
|
var service: WifiBulkTransferService?
|
||||||
|
let environment = makeEnvironment(sendNoisePayload: { typed, peer in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
|
||||||
|
let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true }
|
||||||
|
offers.append(offer.transferID)
|
||||||
|
guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
|
||||||
|
// Deliver the decline twice; the fallback must still fire once.
|
||||||
|
service?.handleResponsePayload(decline, from: peer)
|
||||||
|
service?.handleResponsePayload(decline, from: peer)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
|
||||||
|
service = sut
|
||||||
|
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
#expect(fallbacks.count == 1)
|
||||||
|
#expect(sut._test_activeOutgoingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Responses from the wrong peer are ignored")
|
||||||
|
func wrongPeerResponseIgnored() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
var service: WifiBulkTransferService?
|
||||||
|
let environment = makeEnvironment(sendNoisePayload: { typed, _ in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
|
||||||
|
let offer = WifiBulkOffer.decode(typed.dropFirst()),
|
||||||
|
let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
|
||||||
|
// Decline arrives from an unrelated peer: must be ignored, so the
|
||||||
|
// transfer ends via offer timeout instead.
|
||||||
|
service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788"))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
|
||||||
|
service = sut
|
||||||
|
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
// Not fallen back before the offer timeout window…
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
// …but the timeout still cleans up.
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("User cancel tears down without BLE fallback")
|
||||||
|
func userCancelDoesNotFallBack() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let progress = WifiBulkTestBox<String>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
|
||||||
|
service.cancelTransfer(transferId: "t-cancel")
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
#expect(progress.values.contains("cancel:t-cancel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver declines an offer that exceeds its size cap")
|
||||||
|
func receiverDeclinesOversizedOffer() async {
|
||||||
|
let responses = WifiBulkTestBox<Data>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { typed, _ in
|
||||||
|
if typed.first == NoisePayloadType.bulkTransferResponse.rawValue {
|
||||||
|
responses.append(Data(typed.dropFirst()))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
let offer = WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength),
|
||||||
|
fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
|
||||||
|
payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength),
|
||||||
|
token: Data(repeating: 9, count: WifiBulkWire.tokenLength),
|
||||||
|
serviceName: "0011223344556677"
|
||||||
|
)
|
||||||
|
if let encoded = offer.encode() {
|
||||||
|
service.handleOfferPayload(encoded, from: Self.peer)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { responses.count == 1 })
|
||||||
|
let response = WifiBulkResponse.decode(responses.values[0])
|
||||||
|
#expect(response?.accepted == false)
|
||||||
|
#expect(response?.transferID == offer.transferID)
|
||||||
|
#expect(service._test_activeIncomingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Malformed offers are dropped without a response")
|
||||||
|
func malformedOfferDropped() async {
|
||||||
|
let responses = WifiBulkTestBox<Data>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { typed, _ in
|
||||||
|
responses.append(typed)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer)
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
#expect(responses.count == 0)
|
||||||
|
#expect(service._test_activeIncomingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("stop() tears down active transfers without falling back")
|
||||||
|
func stopTearsDownWithoutFallback() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in true }),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
|
||||||
|
service.stop()
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
public enum FileTransferLimits {
|
public enum FileTransferLimits {
|
||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Ceiling for transfers negotiated onto the peer-to-peer Wi-Fi bulk
|
||||||
|
/// channel. The receiver enforces it against the size in the accepted
|
||||||
|
/// offer; the Bluetooth path keeps `maxPayloadBytes`.
|
||||||
|
public static let maxWifiBulkPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
@@ -17,7 +21,7 @@ public enum FileTransferLimits {
|
|||||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static func isValidPayload(_ size: Int) -> Bool {
|
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= limit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Feature capabilities a peer advertises in its announce packet.
|
||||||
|
///
|
||||||
|
/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the
|
||||||
|
/// wire form grows only when high bits are assigned. Decoders keep the low 64
|
||||||
|
/// bits and ignore any longer field, and unknown bits are preserved verbatim —
|
||||||
|
/// old clients skip the TLV entirely, new clients degrade per-feature.
|
||||||
|
public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||||
|
public let rawValue: UInt64
|
||||||
|
|
||||||
|
public init(rawValue: UInt64) {
|
||||||
|
self.rawValue = rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let prekeys = PeerCapabilities(rawValue: 1 << 0)
|
||||||
|
public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1)
|
||||||
|
public static let gateway = PeerCapabilities(rawValue: 1 << 2)
|
||||||
|
public static let groups = PeerCapabilities(rawValue: 1 << 3)
|
||||||
|
public static let board = PeerCapabilities(rawValue: 1 << 4)
|
||||||
|
public static let vouch = PeerCapabilities(rawValue: 1 << 5)
|
||||||
|
public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6)
|
||||||
|
|
||||||
|
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||||
|
/// empty set is distinguishable from an absent TLV.
|
||||||
|
public func encoded() -> Data {
|
||||||
|
var value = rawValue
|
||||||
|
var bytes = Data()
|
||||||
|
repeat {
|
||||||
|
bytes.append(UInt8(truncatingIfNeeded: value))
|
||||||
|
value >>= 8
|
||||||
|
} while value != 0
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts any length; bytes beyond the low 64 bits are ignored for
|
||||||
|
/// forward compatibility.
|
||||||
|
public init(encoded data: Data) {
|
||||||
|
var value: UInt64 = 0
|
||||||
|
for (index, byte) in data.prefix(8).enumerated() {
|
||||||
|
value |= UInt64(byte) << (8 * index)
|
||||||
|
}
|
||||||
|
self.init(rawValue: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// PeerCapabilitiesTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import BitFoundation
|
||||||
|
|
||||||
|
struct PeerCapabilitiesTests {
|
||||||
|
@Test
|
||||||
|
func encodingIsMinimalAndRoundTrips() {
|
||||||
|
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||||
|
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||||
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
|
|
||||||
|
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||||
|
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||||
|
|
||||||
|
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
|
||||||
|
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||||
|
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||||
|
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decodingToleratesUnknownBitsAndOversizedFields() {
|
||||||
|
// Unknown bits survive a round-trip untouched.
|
||||||
|
let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF]))
|
||||||
|
#expect(unknown.rawValue == 0xFFFF)
|
||||||
|
#expect(unknown.contains(.gateway))
|
||||||
|
|
||||||
|
// Fields longer than 8 bytes keep the low 64 bits and ignore the rest.
|
||||||
|
let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB])
|
||||||
|
#expect(PeerCapabilities(encoded: oversized) == .prekeys)
|
||||||
|
|
||||||
|
// Empty value decodes to no capabilities.
|
||||||
|
#expect(PeerCapabilities(encoded: Data()) == [])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user