From 1cf4ea9e57c9acf4d4014d2c71463a6663819fe1 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 17 Oct 2025 22:43:35 +0200 Subject: [PATCH] Allow file fragments to account for protocol overhead --- bitchat/Services/BLEService.swift | 14 +++- bitchat/Utils/FileTransferLimits.swift | 10 +++ .../Fragmentation/FragmentationTests.swift | 65 ++++++++++++++++++- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index e3f57639..64e1f1a9 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -1142,9 +1142,19 @@ final class BLEService: NSObject { // Check cumulative size before storing this fragment let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0 - guard currentSize + fragmentData.count <= FileTransferLimits.maxPayloadBytes else { + let assemblyLimit: Int = { + if originalType == MessageType.fileTransfer.rawValue { + // Allow headroom for TLV metadata and binary framing overhead. + return FileTransferLimits.maxFramedFileBytes + } + return FileTransferLimits.maxPayloadBytes + }() + guard currentSize + fragmentData.count <= assemblyLimit else { // Exceeds size limit - evict this assembly - SecureLogger.warning("🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes), evicting", category: .security) + SecureLogger.warning( + "🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes > \(assemblyLimit)), evicting", + category: .security + ) incomingFragments.removeValue(forKey: key) fragmentMetadata.removeValue(forKey: key) shouldReassemble = false diff --git a/bitchat/Utils/FileTransferLimits.swift b/bitchat/Utils/FileTransferLimits.swift index c9e6239a..e30a0a26 100644 --- a/bitchat/Utils/FileTransferLimits.swift +++ b/bitchat/Utils/FileTransferLimits.swift @@ -8,6 +8,16 @@ enum FileTransferLimits { static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB /// Compressed images after downscaling should comfortably fit under this budget. static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB + /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads. + static let maxFramedFileBytes: Int = { + let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs + let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes + let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize + + BinaryProtocol.senderIDSize + + BinaryProtocol.recipientIDSize + + BinaryProtocol.signatureSize + return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead + }() static func isValidPayload(_ size: Int) -> Bool { size <= maxPayloadBytes diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 7e51bad9..577681a2 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -92,6 +92,62 @@ struct FragmentationTests { #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 2048) } + + @Test("Max-sized file transfer survives reassembly") + func maxSizedFileTransferSurvivesReassembly() async throws { + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager + ) + let capture = CaptureDelegate() + ble.delegate = capture + + let remoteID = PeerID(str: "CAFEBABECAFEBABE") + let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes) + let filePacket = BitchatFilePacket( + fileName: "limit.bin", + fileSize: UInt64(fileContent.count), + mimeType: "application/octet-stream", + content: fileContent + ) + let encoded = try #require(filePacket.encode(), "File packet encoding failed") + + let packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: remoteID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encoded, + signature: nil, + ttl: 7, + version: 2 + ) + + let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false) + #expect(!fragments.isEmpty) + + for (i, fragment) in fragments.enumerated() { + let delay = 5 * Double(i) * 0.001 + Task { + try await sleep(delay) + ble._test_handlePacket(fragment, fromPeerID: remoteID) + } + } + + try await sleep(1.0) + + let message = try #require(capture.receivedMessages.first, "Expected file transfer message") + #expect(message.content.hasPrefix("[file]")) + + if let fileName = message.content.split(separator: " ").last { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesRoot = base.appendingPathComponent("files", isDirectory: true) + let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true) + let url = incoming.appendingPathComponent(String(fileName)) + try? FileManager.default.removeItem(at: url) + } + } @Test("Invalid fragment header is ignored") func invalidFragmentHeaderIsIgnored() async throws { @@ -142,7 +198,10 @@ struct FragmentationTests { extension FragmentationTests { private final class CaptureDelegate: BitchatDelegate { var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = [] - func didReceiveMessage(_ message: BitchatMessage) {} + var receivedMessages: [BitchatMessage] = [] + func didReceiveMessage(_ message: BitchatMessage) { + receivedMessages.append(message) + } func didConnectToPeer(_ peerID: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} @@ -173,8 +232,8 @@ extension FragmentationTests { } // Helper: fragment a packet using the same header format BLEService expects - private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] { - let fullData = packet.toBinaryData() ?? Data() + private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] { + guard let fullData = packet.toBinaryData(padding: pad) else { return [] } let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) }) let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in Data(fullData[off..