diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e35..7742abb5c6 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -16184,187 +16184,187 @@ } }, "content.delivery.reason.private_media_too_many_fragments" : { - "comment" : "Failure reason when private media exceeds the Android-compatible fragment limit", + "comment" : "Failure reason when private media exceeds the recipient's fragment limit; %lld is that limit", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "الملف كبير جدًا على تطبيق جهة الاتصال هذه (أكثر من 256 جزءًا عبر الشبكة المتداخلة)" + "value" : "الملف كبير جدًا على تطبيق جهة الاتصال هذه (أكثر من %lld جزءًا عبر الشبكة المتداخلة)" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "এই কন্টাক্টের ক্লায়েন্টের জন্য ফাইলটি খুব বড় (256টির বেশি মেশ ফ্র্যাগমেন্ট)" + "value" : "এই কন্টাক্টের ক্লায়েন্টের জন্য ফাইলটি খুব বড় (%lldটির বেশি মেশ ফ্র্যাগমেন্ট)" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Datei ist zu groß für den Client dieses Kontakts (mehr als 256 Mesh-Fragmente)" + "value" : "Datei ist zu groß für den Client dieses Kontakts (mehr als %lld Mesh-Fragmente)" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "File is too large for this contact's client (more than 256 mesh fragments)" + "value" : "File is too large for this contact's client (more than %lld mesh fragments)" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "El archivo es demasiado grande para el cliente de este contacto (más de 256 fragmentos de malla)" + "value" : "El archivo es demasiado grande para el cliente de este contacto (más de %lld fragmentos de malla)" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "فایل برای کلاینت این مخاطب خیلی بزرگ است (بیش از 256 قطعهٔ مش)" + "value" : "فایل برای کلاینت این مخاطب خیلی بزرگ است (بیش از %lld قطعهٔ مش)" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "Masyadong malaki ang file para sa client ng contact na ito (mahigit 256 mesh fragment)" + "value" : "Masyadong malaki ang file para sa client ng contact na ito (mahigit %lld mesh fragment)" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Le fichier est trop volumineux pour le client de ce contact (plus de 256 fragments mesh)" + "value" : "Le fichier est trop volumineux pour le client de ce contact (plus de %lld fragments mesh)" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "הקובץ גדול מדי עבור הקליינט של איש הקשר הזה (יותר מ-256 מקטעי mesh)" + "value" : "הקובץ גדול מדי עבור הקליינט של איש הקשר הזה (יותר מ-%lld מקטעי mesh)" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "यह फ़ाइल इस संपर्क के क्लाइंट के लिए बहुत बड़ी है (256 से ज़्यादा मेश फ़्रैगमेंट)" + "value" : "यह फ़ाइल इस संपर्क के क्लाइंट के लिए बहुत बड़ी है (%lld से ज़्यादा मेश फ़्रैगमेंट)" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "File terlalu besar untuk klien kontak ini (lebih dari 256 fragmen mesh)" + "value" : "File terlalu besar untuk klien kontak ini (lebih dari %lld fragmen mesh)" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Il file è troppo grande per il client di questo contatto (più di 256 frammenti mesh)" + "value" : "Il file è troppo grande per il client di questo contatto (più di %lld frammenti mesh)" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "このファイルはこの連絡先のクライアントには大きすぎます(メッシュフラグメントが256個を超えています)" + "value" : "このファイルはこの連絡先のクライアントには大きすぎます(メッシュフラグメントが%lld個を超えています)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연락처의 클라이언트에는 파일이 너무 커요(메시 조각 256개 초과)" + "value" : "이 연락처의 클라이언트에는 파일이 너무 커요(메시 조각 %lld개 초과)" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "Fail terlalu besar untuk klien kenalan ini (lebih daripada 256 fragmen mesh)" + "value" : "Fail terlalu besar untuk klien kenalan ini (lebih daripada %lld fragmen mesh)" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "यो सम्पर्कको क्लाइन्टका लागि फाइल धेरै ठूलो छ (256 भन्दा बढी मेश खण्डहरू)" + "value" : "यो सम्पर्कको क्लाइन्टका लागि फाइल धेरै ठूलो छ (%lld भन्दा बढी मेश खण्डहरू)" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Het bestand is te groot voor de client van dit contact (meer dan 256 mesh-fragmenten)" + "value" : "Het bestand is te groot voor de client van dit contact (meer dan %lld mesh-fragmenten)" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Plik jest za duży dla klienta tego kontaktu (ponad 256 fragmentów mesh)" + "value" : "Plik jest za duży dla klienta tego kontaktu (ponad %lld fragmentów mesh)" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "O ficheiro é demasiado grande para o cliente deste contacto (mais de 256 fragmentos da malha)" + "value" : "O ficheiro é demasiado grande para o cliente deste contacto (mais de %lld fragmentos da malha)" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "O arquivo é grande demais para o cliente deste contato (mais de 256 fragmentos da malha)" + "value" : "O arquivo é grande demais para o cliente deste contato (mais de %lld fragmentos da malha)" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Файл слишком большой для клиента этого контакта (больше 256 фрагментов mesh-сети)" + "value" : "Файл слишком большой для клиента этого контакта (больше %lld фрагментов mesh-сети)" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Filen är för stor för den här kontaktens klient (fler än 256 mesh-fragment)" + "value" : "Filen är för stor för den här kontaktens klient (fler än %lld mesh-fragment)" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "இந்தத் தொடர்பின் கிளையண்டுக்கு இந்தக் கோப்பு மிகப் பெரியது (256-க்கும் மேற்பட்ட மெஷ் துண்டுகள்)" + "value" : "இந்தத் தொடர்பின் கிளையண்டுக்கு இந்தக் கோப்பு மிகப் பெரியது (%lld-க்கும் மேற்பட்ட மெஷ் துண்டுகள்)" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ไฟล์ใหญ่เกินไปสำหรับไคลเอ็นต์ของผู้ติดต่อรายนี้ (เกิน 256 ส่วนย่อยของ mesh)" + "value" : "ไฟล์ใหญ่เกินไปสำหรับไคลเอ็นต์ของผู้ติดต่อรายนี้ (เกิน %lld ส่วนย่อยของ mesh)" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Dosya bu kişinin istemcisi için çok büyük (256'dan fazla mesh parçası)" + "value" : "Dosya bu kişinin istemcisi için çok büyük (mesh parça sınırı: %lld)" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Файл завеликий для клієнта цього контакту (понад 256 фрагментів mesh-мережі)" + "value" : "Файл завеликий для клієнта цього контакту (понад %lld фрагментів mesh-мережі)" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "یہ فائل اس رابطے کے کلائنٹ کے لیے بہت بڑی ہے (256 سے زیادہ میش ٹکڑے)" + "value" : "یہ فائل اس رابطے کے کلائنٹ کے لیے بہت بڑی ہے (%lld سے زیادہ میش ٹکڑے)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp quá lớn so với ứng dụng của liên hệ này (hơn 256 phân mảnh mesh)" + "value" : "Tệp quá lớn so với ứng dụng của liên hệ này (hơn %lld phân mảnh mesh)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "文件太大,这位联系人的客户端无法接收(超过 256 个网状网络分片)" + "value" : "文件太大,这位联系人的客户端无法接收(超过 %lld 个网状网络分片)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "檔案太大,這位聯絡人的用戶端無法接收(超過 256 個網狀網路分段)" + "value" : "檔案太大,這位聯絡人的用戶端無法接收(超過 %lld 個網狀網路分段)" } } } diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index d45f732380..046cb78d59 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -165,6 +165,7 @@ struct AnnouncementPacket { /// `[version=0x01][type][length][value]...` /// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities` /// - TLV `0x02`: 32-byte Ed25519 signing public key +/// - TLV `0x03`: optional 2-byte big-endian reassembly fragment ceiling /// /// Unknown TLVs are skipped for forward compatibility. Unknown versions, /// duplicates, non-canonical capability fields, and malformed lengths are @@ -172,19 +173,49 @@ struct AnnouncementPacket { struct AuthenticatedPeerStatePacket: Equatable { static let currentVersion: UInt8 = 1 static let signingPublicKeyLength = 32 + static let maxReassemblyFragmentsLength = 2 let capabilities: PeerCapabilities let signingPublicKey: Data + /// How many BLE fragments this peer will reassemble for one packet, as it + /// reported inside the established Noise session. + /// + /// The `.privateMedia` capability bit says a peer understands encrypted + /// media; it says nothing about how much of it that peer can hold. Before + /// this TLV existed, senders inferred the ceiling from the packet type — + /// 256 for the directed migration fallback, the full local ceiling for + /// anything encrypted — which is only correct while every client that + /// implements `0x20` also has a large reassembler. + /// + /// `nil` means the peer did not advertise one, which is the case for every + /// client released before this TLV. Senders fall back to the type proxy + /// there; see `BLEFragmentCeilingPolicy`. + let maxReassemblyFragments: UInt16? + + init( + capabilities: PeerCapabilities, + signingPublicKey: Data, + maxReassemblyFragments: UInt16? = nil + ) { + self.capabilities = capabilities + self.signingPublicKey = signingPublicKey + self.maxReassemblyFragments = maxReassemblyFragments + } private enum TLVType: UInt8 { case capabilities = 0x01 case signingPublicKey = 0x02 + case maxReassemblyFragments = 0x03 } func encode() -> Data? { guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil } let capabilityBytes = capabilities.encoded() guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil } + // Zero would advertise a peer that can reassemble nothing, which is + // indistinguishable in effect from refusing every fragmented packet. + // Omit the TLV instead of putting a meaningless number on the wire. + if let maxReassemblyFragments { guard maxReassemblyFragments > 0 else { return nil } } var data = Data([Self.currentVersion]) data.append(TLVType.capabilities.rawValue) @@ -193,6 +224,11 @@ struct AuthenticatedPeerStatePacket: Equatable { data.append(TLVType.signingPublicKey.rawValue) data.append(UInt8(signingPublicKey.count)) data.append(signingPublicKey) + if let maxReassemblyFragments { + data.append(TLVType.maxReassemblyFragments.rawValue) + data.append(UInt8(Self.maxReassemblyFragmentsLength)) + data.append(contentsOf: withUnsafeBytes(of: maxReassemblyFragments.bigEndian) { Data($0) }) + } return data } @@ -202,6 +238,7 @@ struct AuthenticatedPeerStatePacket: Equatable { var offset = 1 var capabilities: PeerCapabilities? var signingPublicKey: Data? + var maxReassemblyFragments: UInt16? while offset < data.count { guard offset + 2 <= data.count else { return nil } @@ -228,13 +265,26 @@ struct AuthenticatedPeerStatePacket: Equatable { guard signingPublicKey == nil, value.count == Self.signingPublicKeyLength else { return nil } signingPublicKey = value + + case .maxReassemblyFragments: + // Rejected rather than skipped: a peer that meant to constrain + // us but sent a field we cannot read must not be treated as + // having said nothing, because "said nothing" falls back to + // the permissive type proxy. + guard maxReassemblyFragments == nil, + value.count == Self.maxReassemblyFragmentsLength else { return nil } + let decoded = (UInt16(value[value.startIndex]) << 8) + | UInt16(value[value.startIndex + 1]) + guard decoded > 0 else { return nil } + maxReassemblyFragments = decoded } } guard let capabilities, let signingPublicKey else { return nil } return AuthenticatedPeerStatePacket( capabilities: capabilities, - signingPublicKey: signingPublicKey + signingPublicKey: signingPublicKey, + maxReassemblyFragments: maxReassemblyFragments ) } } diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 31656cb40b..bd9abb35b5 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -35,7 +35,8 @@ struct BLEFragmentHeader: Equatable { let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9])) let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11])) - guard total > 0 && total <= 10_000 && index >= 0 && index < total else { + guard total > 0 && total <= BLEFragmentAssemblyBuffer.maxReassemblyFragments + && index >= 0 && index < total else { return nil } @@ -54,6 +55,19 @@ struct BLEFragmentHeader: Equatable { } struct BLEFragmentAssemblyBuffer { + /// The largest fragment count this client will ever begin reassembling. + /// + /// A sender cannot know this by inspection, so it is also the value we + /// advertise to authenticated peers (`AuthenticatedPeerStatePacket`'s + /// `maxReassemblyFragments` TLV). Naming it keeps the guard below and the + /// advertised number from drifting apart: a client that raises one and + /// forgets the other either rejects media it promised to accept, or + /// promises more than it can hold. + /// + /// Bounded by `UInt16.max` because the wire header carries `total` as a + /// big-endian `UInt16`. + static let maxReassemblyFragments = 10_000 + enum AppendResult: Equatable { case stored(header: BLEFragmentHeader, started: Bool) case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool) diff --git a/bitchat/Services/BLE/BLEFragmentCeilingPolicy.swift b/bitchat/Services/BLE/BLEFragmentCeilingPolicy.swift new file mode 100644 index 0000000000..1d3ae256f6 --- /dev/null +++ b/bitchat/Services/BLE/BLEFragmentCeilingPolicy.swift @@ -0,0 +1,76 @@ +import BitFoundation +import Foundation + +/// Decides how many BLE fragments one outbound packet may be split into for a +/// particular recipient. +/// +/// Before an explicit ceiling existed on the wire, this was inferred from the +/// packet type: a directed `fileTransfer` was assumed to be the raw migration +/// fallback aimed at current Android (256 fragments), and anything else was +/// assumed to be aimed at a client with a reassembler as large as our own. +/// That proxy holds only while "implements the encrypted `0x20` path" and "has +/// a large reassembler" are the same population. A client that adopts `0x20` +/// with a smaller buffer breaks it in the silent direction: we would send +/// fragments the peer drops, and the transfer fails with no local signal. +/// +/// An authenticated peer that advertised `maxReassemblyFragments` has stated +/// its own limit inside the Noise session, so that number is used instead of +/// the proxy — in both directions. A peer asking for less than 256 is the case +/// the proxy gets dangerously wrong; a peer asking for more has told us the +/// migration cap does not apply to it. +/// +/// Deliberately free of BLE, Combine and app-model imports: the whole decision +/// is inputs to outputs, and every branch below is exercised directly in +/// `BLEFragmentCeilingPolicyTests`. +enum BLEFragmentCeilingPolicy { + /// Why a particular ceiling was chosen. Carried so the caller can log and + /// explain a rejection, and so tests assert the reasoning rather than just + /// the number — two sources can agree on a value by coincidence. + enum Source: Equatable { + /// The recipient advertised its own limit in authenticated peer state. + case negotiated + /// No advertisement; the recipient is assumed to be a released client + /// on the directed raw-file migration path. + case migrationFallbackProxy + /// No advertisement and no reason to assume a small reassembler. + case localCeiling + } + + struct Decision: Equatable { + let maxFragments: Int + let source: Source + + func admits(fragmentCount: Int) -> Bool { + fragmentCount <= maxFragments + } + } + + /// We never originate a transfer larger than we would be willing to + /// reassemble ourselves. A peer advertising a ceiling above our own is not + /// treated as an invitation to exceed it: the symmetric bound keeps one + /// side's configuration change from silently raising the other side's + /// memory exposure, and nothing today needs more. + static func decide( + packetType: UInt8, + isDirectedToPeer: Bool, + negotiatedCeiling: UInt16?, + localCeiling: Int = BLEFragmentAssemblyBuffer.maxReassemblyFragments, + migrationFallbackCeiling: Int = BLEOutboundFragmentPlanner.privateMediaV1MaxFragments + ) -> Decision { + if let negotiatedCeiling, negotiatedCeiling > 0 { + return Decision( + maxFragments: min(Int(negotiatedCeiling), localCeiling), + source: .negotiated + ) + } + + if isDirectedToPeer, packetType == MessageType.fileTransfer.rawValue { + return Decision( + maxFragments: min(migrationFallbackCeiling, localCeiling), + source: .migrationFallbackProxy + ) + } + + return Decision(maxFragments: localCeiling, source: .localCeiling) + } +} diff --git a/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift index 461d934ce0..751f18a0df 100644 --- a/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift +++ b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift @@ -5,6 +5,22 @@ struct BLEAuthenticatedPeerStateObservation { let fingerprint: String let sessionGeneration: UUID let capabilities: PeerCapabilities + /// The peer's own reassembly bound, if it advertised one. Bound to the + /// generation like every other field here: a replacement session must + /// re-state its ceiling rather than inherit the previous one. + let maxReassemblyFragments: UInt16? + + init( + fingerprint: String, + sessionGeneration: UUID, + capabilities: PeerCapabilities, + maxReassemblyFragments: UInt16? = nil + ) { + self.fingerprint = fingerprint + self.sessionGeneration = sessionGeneration + self.capabilities = capabilities + self.maxReassemblyFragments = maxReassemblyFragments + } } struct BLEPrivateMediaProofTimeoutMarker { @@ -163,14 +179,16 @@ final class BLEPrivateMediaSessionStore: @unchecked Sendable { for peerID: PeerID, fingerprint: String, generation: UUID, - capabilities: PeerCapabilities + capabilities: PeerCapabilities, + maxReassemblyFragments: UInt16? = nil ) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? { lock.withLock { guard sessionGenerations[peerID] == generation else { return nil } authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation( fingerprint: fingerprint, sessionGeneration: generation, - capabilities: capabilities + capabilities: capabilities, + maxReassemblyFragments: maxReassemblyFragments ) proofTimeoutMarkers.removeValue(forKey: peerID) proofWatchdogs.removeValue(forKey: peerID) @@ -347,6 +365,22 @@ final class BLEPrivateMediaSessionStore: @unchecked Sendable { } extension BLEPrivateMediaSessionStore { + /// The recipient's advertised reassembly ceiling, or nil when it did not + /// advertise one. + /// + /// Only honoured for an observation pinned to the peer's *current* + /// generation. A ceiling read off a superseded session is a statement by + /// whoever held that session, and after a rekey the counterpart may be a + /// different client entirely — falling back to the type proxy is the + /// conservative answer, not reusing a stale number. + func negotiatedFragmentCeiling(for peerID: PeerID) -> UInt16? { + let inputs = policyInputs(for: peerID) + guard let generation = inputs.sessionGeneration, + let authenticated = inputs.authenticatedState, + authenticated.sessionGeneration == generation else { return nil } + return authenticated.maxReassemblyFragments + } + /// The current generation iff its authenticated peer state proved the /// private-media capability (and, when required, durable receipts). func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5f9b97af03..83d4eb9233 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2009,13 +2009,19 @@ final class BLEService: NSObject { // unaffected. Run the same planner the scheduler will use, after route // application, and reject before reserving a transfer slot or writing // any fragment. - // TODO(#1434): negotiate an explicit per-peer fragment limit so a future - // Android client that adopts the encrypted 0x20 path but still caps its - // reassembler can advertise its own ceiling instead of relying on the - // capability/type proxy above. + // The proxy above is now only the fallback: a peer that advertised its + // own ceiling in authenticated `0x21` state has it honoured instead, + // which is what closes #1434. That also means the check can no longer + // be restricted to `fileTransfer` — an advertised ceiling constrains + // encrypted media too, and that is precisely the case (small + // reassembler behind the `0x20` path) the type proxy misses. if let transferId, - let recipientPeerID = PeerID(hexData: packetToSend.recipientID), - packetToSend.type == MessageType.fileTransfer.rawValue { + let recipientPeerID = PeerID(hexData: packetToSend.recipientID) { + let ceiling = BLEFragmentCeilingPolicy.decide( + packetType: packetToSend.type, + isDirectedToPeer: true, + negotiatedCeiling: privateMediaSessions.negotiatedFragmentCeiling(for: recipientPeerID.toShort()) + ) let compatibilityRequest = BLEOutboundFragmentTransferRequest( packet: packetToSend, pad: padForBLE, @@ -2027,17 +2033,20 @@ final class BLEService: NSObject { for: compatibilityRequest, defaultChunkSize: defaultFragmentSize, bleMaxMTU: bleMaxMTU - ), BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(plan) else { + ), ceiling.admits(fragmentCount: plan.totalFragments) else { SecureLogger.warning( - "Private media rejected: exceeds cross-platform 256-fragment limit", + "Private media rejected: \(String(describing: ceiling.source)) limit of \(ceiling.maxFragments) fragments", category: .security ) TransferProgressManager.shared.rejectBeforeStart( id: transferId, reason: String( - localized: "content.delivery.reason.private_media_too_many_fragments", - defaultValue: "File is too large for this contact's client (more than 256 mesh fragments)", - comment: "Failure reason when private media exceeds the Android-compatible fragment limit" + format: String( + localized: "content.delivery.reason.private_media_too_many_fragments", + defaultValue: "File is too large for this contact's client (more than %lld mesh fragments)", + comment: "Failure reason when private media exceeds the recipient's fragment limit; %lld is that limit" + ), + ceiling.maxFragments ) ) if requiresPrivateMediaAdmission { @@ -4234,7 +4243,12 @@ extension BLEService { let capabilities = localIdentityState.snapshot().advertisedCapabilities let state = AuthenticatedPeerStatePacket( capabilities: capabilities, - signingPublicKey: noiseService.getSigningPublicKeyData() + signingPublicKey: noiseService.getSigningPublicKeyData(), + // Our own reassembler's bound, so a peer sizing a transfer for us + // does not have to infer it from the packet type either. + maxReassemblyFragments: UInt16( + clamping: BLEFragmentAssemblyBuffer.maxReassemblyFragments + ) ) guard let payload = BLENoisePayloadFactory.authenticatedPeerState(state) else { SecureLogger.error("Failed to encode authenticated peer state", category: .security) @@ -4308,7 +4322,8 @@ extension BLEService { for: normalizedPeerID, fingerprint: fingerprint, generation: generation, - capabilities: state.capabilities + capabilities: state.capabilities, + maxReassemblyFragments: state.maxReassemblyFragments ) else { return (false, []) } diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index c837ee1de4..6d2f185148 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -178,6 +178,62 @@ struct PacketsTests { #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil) } + @Test + func authenticatedPeerStateRoundTripsTheReassemblyCeiling() throws { + let packet = AuthenticatedPeerStatePacket( + capabilities: [.privateMedia], + signingPublicKey: Data(repeating: 0xA5, count: 32), + maxReassemblyFragments: 256 + ) + + let encoded = try #require(packet.encode()) + #expect(encoded.suffix(4) == Data([0x03, 0x02, 0x01, 0x00])) + #expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet) + } + + @Test + func authenticatedPeerStateOmitsAnAbsentCeilingAndDecodesLegacyPayloadsAsNil() throws { + let packet = AuthenticatedPeerStatePacket( + capabilities: [.privateMedia], + signingPublicKey: Data(repeating: 0xA5, count: 32) + ) + + let encoded = try #require(packet.encode()) + // Every client released before TLV 0x03 emits exactly this — version + // byte, 2-byte capability TLV, 32-byte key TLV, nothing else — and it + // must decode as "did not advertise", not as a ceiling of 0. + #expect(encoded.count == 1 + (2 + 2) + (2 + 32)) + let decoded = try #require(AuthenticatedPeerStatePacket.decode(from: encoded)) + #expect(decoded.maxReassemblyFragments == nil) + } + + @Test + func authenticatedPeerStateRejectsAnUnreadableCeilingRatherThanIgnoringIt() { + let key = Data(repeating: 0x44, count: 32) + let prefix = Data([0x01]) + makeTLV(type: 0x01, value: Data([0x00, 0x01])) + + makeTLV(type: 0x02, value: key) + let ceiling = makeTLV(type: 0x03, value: Data([0x01, 0x00])) + + // Zero means "reassembles nothing" — never a value we should honour. + #expect(AuthenticatedPeerStatePacket.decode(from: prefix + makeTLV(type: 0x03, value: Data([0x00, 0x00]))) == nil) + // Wrong width: a 1- or 4-byte field is a different encoding, not ours. + #expect(AuthenticatedPeerStatePacket.decode(from: prefix + makeTLV(type: 0x03, value: Data([0x01]))) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: prefix + makeTLV(type: 0x03, value: Data([0x00, 0x00, 0x01, 0x00]))) == nil) + // Two ceilings are ambiguous; picking either one is a guess. + #expect(AuthenticatedPeerStatePacket.decode(from: prefix + ceiling + ceiling) == nil) + } + + @Test + func authenticatedPeerStateRefusesToEncodeAZeroCeiling() { + #expect( + AuthenticatedPeerStatePacket( + capabilities: [.privateMedia], + signingPublicKey: Data(repeating: 0xA5, count: 32), + maxReassemblyFragments: 0 + ).encode() == nil + ) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift b/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift new file mode 100644 index 0000000000..3120d894e6 --- /dev/null +++ b/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift @@ -0,0 +1,163 @@ +import BitFoundation +import Foundation +import Testing + +@testable import bitchat + +struct BLEFragmentCeilingPolicyTests { + private let fileTransfer = MessageType.fileTransfer.rawValue + private let noiseEncrypted = MessageType.noiseEncrypted.rawValue + + // MARK: - The proxy, unchanged where nothing was negotiated + + @Test + func directedFileTransferWithoutAnAdvertisementKeepsTheMigrationCap() { + let decision = BLEFragmentCeilingPolicy.decide( + packetType: fileTransfer, + isDirectedToPeer: true, + negotiatedCeiling: nil + ) + + #expect(decision.source == .migrationFallbackProxy) + #expect(decision.maxFragments == BLEOutboundFragmentPlanner.privateMediaV1MaxFragments) + #expect(decision.admits(fragmentCount: 256)) + #expect(!decision.admits(fragmentCount: 257)) + } + + @Test + func encryptedMediaWithoutAnAdvertisementKeepsTheFullLocalCeiling() { + let decision = BLEFragmentCeilingPolicy.decide( + packetType: noiseEncrypted, + isDirectedToPeer: true, + negotiatedCeiling: nil + ) + + #expect(decision.source == .localCeiling) + #expect(decision.maxFragments == BLEFragmentAssemblyBuffer.maxReassemblyFragments) + // The regression the 256 cap caused for iOS→iOS photos in the + // ~120–512 KiB range: those plans must still be admitted. + #expect(decision.admits(fragmentCount: 900)) + } + + @Test + func broadcastFileTransferIsNotTreatedAsTheDirectedMigrationPath() { + let decision = BLEFragmentCeilingPolicy.decide( + packetType: fileTransfer, + isDirectedToPeer: false, + negotiatedCeiling: nil + ) + + #expect(decision.source == .localCeiling) + #expect(decision.maxFragments == BLEFragmentAssemblyBuffer.maxReassemblyFragments) + } + + // MARK: - An advertisement replaces the proxy + + @Test + func anAdvertisedCeilingBelowTheMigrationCapIsHonoured() { + // The case the type proxy gets wrong in the silent direction: a client + // on the encrypted path whose reassembler is smaller than ours. Without + // the advertisement we would happily plan 900 fragments it will drop. + let decision = BLEFragmentCeilingPolicy.decide( + packetType: noiseEncrypted, + isDirectedToPeer: true, + negotiatedCeiling: 128 + ) + + #expect(decision.source == .negotiated) + #expect(decision.maxFragments == 128) + #expect(decision.admits(fragmentCount: 128)) + #expect(!decision.admits(fragmentCount: 129)) + } + + @Test + func anAdvertisedCeilingAboveTheMigrationCapLiftsItForThatPeer() { + // A peer that speaks 0x21 has told us the deployed-Android assumption + // behind the 256 cap does not describe it. + let decision = BLEFragmentCeilingPolicy.decide( + packetType: fileTransfer, + isDirectedToPeer: true, + negotiatedCeiling: 1024 + ) + + #expect(decision.source == .negotiated) + #expect(decision.maxFragments == 1024) + #expect(decision.admits(fragmentCount: 1024)) + } + + @Test + func anAdvertisedCeilingIsClampedToWhatWeWouldReassembleOurselves() { + let local = BLEFragmentAssemblyBuffer.maxReassemblyFragments + let decision = BLEFragmentCeilingPolicy.decide( + packetType: noiseEncrypted, + isDirectedToPeer: true, + negotiatedCeiling: UInt16.max + ) + + #expect(decision.source == .negotiated) + #expect(decision.maxFragments == local) + #expect(!decision.admits(fragmentCount: local + 1)) + } + + @Test + func aZeroAdvertisementFallsBackRatherThanBlockingEveryTransfer() { + // The decoder rejects a zero on the wire, so this can only arrive from + // a future caller passing one through. Treat it as absent: refusing + // every fragment to that peer would be a worse failure than the proxy. + let decision = BLEFragmentCeilingPolicy.decide( + packetType: fileTransfer, + isDirectedToPeer: true, + negotiatedCeiling: 0 + ) + + #expect(decision.source == .migrationFallbackProxy) + #expect(decision.maxFragments == BLEOutboundFragmentPlanner.privateMediaV1MaxFragments) + } + + // MARK: - Invariants across the whole input space + + @Test + func noDecisionEverExceedsTheLocalCeiling() { + let local = BLEFragmentAssemblyBuffer.maxReassemblyFragments + let ceilings: [UInt16?] = [nil, 1, 255, 256, 257, 9_999, 10_000, 10_001, UInt16.max] + let types: [UInt8] = [ + fileTransfer, + noiseEncrypted, + MessageType.message.rawValue, + MessageType.announce.rawValue + ] + + for ceiling in ceilings { + for type in types { + for directed in [true, false] { + let decision = BLEFragmentCeilingPolicy.decide( + packetType: type, + isDirectedToPeer: directed, + negotiatedCeiling: ceiling + ) + #expect(decision.maxFragments >= 1) + #expect(decision.maxFragments <= local) + } + } + } + } + + @Test + func advertisingOurOwnCeilingRoundTripsThroughTheWireField() throws { + // The advertised number has to fit the 2-byte TLV, or we would ship a + // truncated ceiling that reads as a much smaller buffer. + let local = BLEFragmentAssemblyBuffer.maxReassemblyFragments + #expect(local > 0) + #expect(local <= Int(UInt16.max)) + + let packet = AuthenticatedPeerStatePacket( + capabilities: [.privateMedia], + signingPublicKey: Data(repeating: 0x11, count: 32), + maxReassemblyFragments: UInt16(local) + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AuthenticatedPeerStatePacket.decode(from: encoded)) + let advertised = try #require(decoded.maxReassemblyFragments) + #expect(Int(advertised) == local) + } +} diff --git a/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift index b04e6c4111..f1ca308af5 100644 --- a/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift +++ b/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift @@ -189,4 +189,73 @@ struct BLEPrivateMediaSessionStoreTests { ) #expect(store.markPeerStateSend(for: peer, echo: false)) } + + // MARK: - Negotiated reassembly ceiling + + @Test func negotiatedCeilingIsReadableOnlyForTheGenerationThatStatedIt() { + let store = BLEPrivateMediaSessionStore() + let generation = UUID() + _ = store.beginAuthenticatedGeneration( + for: peer, fingerprint: fingerprint, generation: generation + ) + #expect(store.negotiatedFragmentCeiling(for: peer) == nil) + + _ = store.applyAuthenticatedPeerState( + for: peer, + fingerprint: fingerprint, + generation: generation, + capabilities: [.privateMedia], + maxReassemblyFragments: 128 + ) + #expect(store.negotiatedFragmentCeiling(for: peer) == 128) + + // A rekey replaces the counterpart's session state. The old ceiling + // was a statement by whoever held that session, and the replacement + // may be a different client entirely, so it must not be inherited. + // + // Two independent mechanisms enforce this: `beginAuthenticatedGeneration` + // drops the observation, and the accessor refuses one whose pinned + // generation is not current. Removing either alone still passes; this + // expectation fails once both are gone, which is the invariant it is + // here to hold rather than any single line of it. + _ = store.beginAuthenticatedGeneration( + for: peer, fingerprint: fingerprint, generation: UUID() + ) + #expect(store.negotiatedFragmentCeiling(for: peer) == nil) + } + + @Test func peerStateWithoutACeilingLeavesTheSenderOnTheTypeProxy() { + let store = BLEPrivateMediaSessionStore() + let generation = UUID() + _ = store.beginAuthenticatedGeneration( + for: peer, fingerprint: fingerprint, generation: generation + ) + // Every client released before TLV 0x03 lands here. + _ = store.applyAuthenticatedPeerState( + for: peer, + fingerprint: fingerprint, + generation: generation, + capabilities: [.privateMedia] + ) + #expect(store.negotiatedFragmentCeiling(for: peer) == nil) + } + + @Test func clearingTheSessionDropsTheNegotiatedCeiling() { + let store = BLEPrivateMediaSessionStore() + let generation = UUID() + _ = store.beginAuthenticatedGeneration( + for: peer, fingerprint: fingerprint, generation: generation + ) + _ = store.applyAuthenticatedPeerState( + for: peer, + fingerprint: fingerprint, + generation: generation, + capabilities: [.privateMedia], + maxReassemblyFragments: 512 + ) + #expect(store.negotiatedFragmentCeiling(for: peer) == 512) + + _ = store.clearSession(for: peer) + #expect(store.negotiatedFragmentCeiling(for: peer) == nil) + } }