From 878108b372f43234143bf2f67455ba6c5a17868f Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 07:57:29 +0530 Subject: [PATCH 1/3] Carry an explicit reassembly ceiling in authenticated peer state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `.privateMedia` capability bit tells a sender that a peer understands encrypted media. It says nothing about how much of it that peer can hold, so senders have had to infer the ceiling from the packet type instead. Add TLV 0x03 to the authenticated peer-state packet: a 2-byte big-endian count of fragments the peer will reassemble for one packet. Absent means "not advertised", which is what every already-released client sends, so old peers keep decoding unchanged. An unreadable 0x03 (zero, wrong width, duplicated) is rejected rather than skipped like an unknown TLV. Skipping it would read as "said nothing", and "said nothing" falls back to the permissive proxy — the opposite of what a peer sending the field is asking for. Name the receiver's own 10,000-fragment guard while here, so the number we advertise and the number we enforce cannot drift apart. --- bitchat/Protocols/Packets.swift | 52 ++++++++++++++++- .../BLE/BLEFragmentAssemblyBuffer.swift | 16 +++++- bitchatTests/Protocols/PacketsTests.swift | 56 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) 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/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]) From 3ec42763b4c3a875eddd96b493f1b41565e73095 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 08:09:34 +0530 Subject: [PATCH 2/3] Add BLEFragmentCeilingPolicy for the per-recipient fragment bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the inline type check in broadcastPacket into a value: given the packet type, whether it is directed, and whatever the recipient advertised, decide the fragment bound and say which of the three reasons produced it. An advertised ceiling replaces the proxy in both directions. Below 256 is the case the proxy gets wrong silently — we plan fragments the peer drops and see no local failure. Above 256 is a peer telling us the deployed Android assumption behind that cap does not describe it. Every decision is clamped to what we would reassemble ourselves, so one side raising its configuration cannot raise the other side's memory exposure. No caller yet; this commit is the decision and its tests. --- .../BLE/BLEFragmentCeilingPolicy.swift | 76 ++++++++ .../BLEFragmentCeilingPolicyTests.swift | 163 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 bitchat/Services/BLE/BLEFragmentCeilingPolicy.swift create mode 100644 bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift 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/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift b/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift new file mode 100644 index 0000000000..385e023990 --- /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) + } +} From 21b1225fdcc8cbe4537b7ee1d7cd050cf5b8e1e7 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 08:31:22 +0530 Subject: [PATCH 3/3] Honour a negotiated ceiling when planning private media Advertise our own reassembler's bound in authenticated peer state, keep each peer's advertised bound pinned to the generation that stated it, and size outbound transfers against it. Two behaviour changes fall out of this: The preflight no longer only inspects directed `fileTransfer`. An advertised ceiling constrains encrypted media too, and a small reassembler behind the 0x20 path is exactly the case the type proxy cannot see. Peers that advertise nothing keep the old classification, so released clients are unaffected. The rejection message carries the real limit instead of a hardcoded 256, which is no longer always the number in force. The existing key keeps every locale's translation with only the numeral parameterized. Retires TODO(#1434). --- bitchat/Localizable.xcstrings | 62 ++++++++--------- .../BLE/BLEPrivateMediaSessionStore.swift | 38 +++++++++- bitchat/Services/BLE/BLEService.swift | 41 +++++++---- .../BLEFragmentCeilingPolicyTests.swift | 2 +- .../BLEPrivateMediaSessionStoreTests.swift | 69 +++++++++++++++++++ 5 files changed, 165 insertions(+), 47 deletions(-) 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/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/Services/BLEFragmentCeilingPolicyTests.swift b/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift index 385e023990..3120d894e6 100644 --- a/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift +++ b/bitchatTests/Services/BLEFragmentCeilingPolicyTests.swift @@ -124,7 +124,7 @@ struct BLEFragmentCeilingPolicyTests { fileTransfer, noiseEncrypted, MessageType.message.rawValue, - MessageType.announce.rawValue, + MessageType.announce.rawValue ] for ceiling in ceilings { 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) + } }