From 1fd64e9913068645a07eaec263f98ebc69044c8b Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 11 Aug 2026 20:58:57 +0530 Subject: [PATCH] feat: surface private media decode failures in the DM thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When authenticated private media fails local validation, log a structured reason code and post a people-readable system line in the affected DM instead of failing silently. Public mesh transfers keep the existing security-logging path. Decode failures are attacker-triggerable — an authenticated peer can send malformed media as fast as the link allows — so the system lines are rate limited to one per peer per five-minute window. Failures suppressed inside a window are counted and reported on the next line rather than dropped, peers are throttled independently so a flooder cannot silence someone else, and closed windows are pruned so a sender rotating peer IDs cannot grow the throttle's state without bound. A decode failure from a blocked peer posts nothing and reports the payload as handled: blocking is a deliberate drop, not a failure to handle. That return value is currently only observed by tests — handlePrivatePayload is @discardableResult and its one production caller ignores it. Refs #1518 --- bitchat/Localizable.xcstrings | 930 ++++++++++++++++++ bitchat/Protocols/BitchatProtocol.swift | 6 + .../Services/BLE/BLEFileTransferHandler.swift | 41 +- bitchat/Services/BLE/BLEService.swift | 5 + .../PrivateMediaDecodeFailureReason.swift | 80 ++ .../PrivateMediaDecodeFailureThrottle.swift | 74 ++ bitchat/ViewModels/ChatViewModel.swift | 26 + .../BLEFileTransferHandlerTests.swift | 19 + ...PrivateMediaDecodeFailureReasonTests.swift | 26 + ...ivateMediaDecodeFailureThrottleTests.swift | 100 ++ docs/PRIVATE-MEDIA-DECODE-FAILURES.md | 22 + 11 files changed, 1318 insertions(+), 11 deletions(-) create mode 100644 bitchat/Services/PrivateMediaDecodeFailureReason.swift create mode 100644 bitchat/Services/PrivateMediaDecodeFailureThrottle.swift create mode 100644 bitchatTests/Services/PrivateMediaDecodeFailureReasonTests.swift create mode 100644 bitchatTests/Services/PrivateMediaDecodeFailureThrottleTests.swift create mode 100644 docs/PRIVATE-MEDIA-DECODE-FAILURES.md diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..431a83aa 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -13579,6 +13579,936 @@ } } }, + "media.decode.failure.magic_mismatch" : { + "comment" : "System message when file magic bytes mismatch MIME; %@ is the MIME", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر فتح ملف الوسائط — المحتوى لا يطابق النوع المعلن (%@)." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া ফাইল খোলা যায়নি — বিষয়বস্তু ঘোষিত ধরনের সাথে মেলে না (%@)।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "medien-datei ließ sich nicht öffnen — inhalt passt nicht zum angegebenen typ (%@)." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "couldn't open the media file — contents don't match the declared type (%@)." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo abrir el archivo multimedia — el contenido no coincide con el tipo declarado (%@)." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل رسانه باز نشد — محتوا با نوع اعلام‌شده هم‌خوانی ندارد (%@)." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi mabuksan ang media file — hindi tugma ang nilalaman sa idineklarang uri (%@)." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible d'ouvrir le fichier — le contenu ne correspond pas au type déclaré (%@)." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לפתוח את קובץ המדיה — התוכן אינו תואם לסוג שהוצהר (%@)." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया फ़ाइल नहीं खुली — सामग्री घोषित प्रकार से मेल नहीं खाती (%@)।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa membuka berkas media — isinya tidak cocok dengan tipe yang dinyatakan (%@)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile aprire il file multimediale — il contenuto non corrisponde al tipo dichiarato (%@)." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディアファイルを開けませんでした — 中身が申告された形式と一致しません (%@)。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 파일을 열 수 없습니다 — 내용이 선언된 형식과 다릅니다 (%@)." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat membuka fail media — kandungan tidak sepadan dengan jenis yang diisytiharkan (%@)." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया फाइल खोल्न सकिएन — सामग्री घोषित प्रकारसँग मिल्दैन (%@)।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kon het mediabestand niet openen — de inhoud komt niet overeen met het opgegeven type (%@)." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie udało się otworzyć pliku multimedialnego — zawartość nie zgadza się z deklarowanym typem (%@)." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível abrir o ficheiro de media — o conteúdo não corresponde ao tipo declarado (%@)." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível abrir o arquivo de mídia — o conteúdo não corresponde ao tipo declarado (%@)." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось открыть медиафайл — содержимое не соответствует заявленному типу (%@)." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunde inte öppna mediefilen — innehållet stämmer inte med den angivna typen (%@)." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியா கோப்பைத் திறக்க முடியவில்லை — உள்ளடக்கம் அறிவிக்கப்பட்ட வகையுடன் பொருந்தவில்லை (%@)." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดไฟล์สื่อไม่ได้ — เนื้อหาไม่ตรงกับประเภทที่ระบุ (%@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "medya dosyası açılamadı — içerik belirtilen türle uyuşmuyor (%@)." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося відкрити медіафайл — вміст не відповідає заявленому типу (%@)." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا فائل نہیں کھل سکی — مواد بیان کردہ قسم سے میل نہیں کھاتا (%@)۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không mở được tệp phương tiện — nội dung không khớp với loại đã khai báo (%@)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开媒体文件 — 内容与声明的类型不符 (%@)。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟媒體檔案 — 內容與宣告的類型不符 (%@)。" + } + } + } + }, + "media.decode.failure.malformed" : { + "comment" : "System message when a private media payload fails to decode", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر فتح ملف الوسائط — لم تُفكّ شفرة الحمولة. قد يكون المرسل يستخدم إصدارًا يحزم الوسائط بشكل مختلف." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া ফাইল খোলা যায়নি — পেলোড ডিকোড হয়নি। প্রেরকের অ্যাপ সংস্করণ মিডিয়া অন্যভাবে প্যাক করতে পারে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "medien-datei ließ sich nicht öffnen — die nutzlast wurde nicht dekodiert. der absender nutzt vielleicht eine version, die medien anders packt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "couldn't open the media file — the payload didn't decode. the sender may be on an app version that packs media differently." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo abrir el archivo multimedia — el contenido no se decodificó. quien lo envía puede tener una versión que empaqueta los archivos de otra forma." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل رسانه باز نشد — محتوا رمزگشایی نشد. شاید فرستنده از نسخه‌ای استفاده می‌کند که رسانه را جور دیگری بسته‌بندی می‌کند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi mabuksan ang media file — hindi na-decode ang payload. baka ibang bersyon ng app ang nagpadala at iba ang pagkakabalot ng media." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible d'ouvrir le fichier — les données ne se décodent pas. l'expéditeur utilise peut-être une version qui empaquette les médias autrement." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לפתוח את קובץ המדיה — לא הצלחנו לפענח את התוכן. ייתכן שהשולח משתמש בגרסה שאורזת מדיה אחרת." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया फ़ाइल नहीं खुली — पेलोड डिकोड नहीं हुआ। भेजने वाला शायद ऐसा ऐप संस्करण चला रहा है जो मीडिया अलग तरह से पैक करता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa membuka berkas media — muatannya gagal didekode. pengirim mungkin memakai versi aplikasi yang mengemas media secara berbeda." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile aprire il file multimediale — il contenuto non si decodifica. chi lo invia potrebbe avere una versione che impacchetta i media in modo diverso." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディアファイルを開けませんでした — データをデコードできません。送信者のアプリはメディアの詰め方が違うバージョンかもしれません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 파일을 열 수 없습니다 — 페이로드를 디코딩하지 못했습니다. 보낸 사람의 앱 버전이 미디어를 다르게 담을 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat membuka fail media — muatan gagal dinyahkod. penghantar mungkin menggunakan versi apl yang membungkus media secara berbeza." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया फाइल खोल्न सकिएन — पेलोड डिकोड भएन। पठाउनेको एप संस्करणले मिडिया फरक तरिकाले प्याक गर्न सक्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kon het mediabestand niet openen — de payload decodeerde niet. de afzender heeft mogelijk een versie die media anders inpakt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie udało się otworzyć pliku multimedialnego — nie zdekodowano zawartości. nadawca może mieć wersję, która inaczej pakuje media." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível abrir o ficheiro de media — o conteúdo não descodificou. quem enviou pode ter uma versão que empacota media de outra forma." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível abrir o arquivo de mídia — o conteúdo não decodificou. quem enviou pode ter uma versão que empacota mídia de outro jeito." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось открыть медиафайл — содержимое не декодировалось. у отправителя может быть версия, которая упаковывает медиа иначе." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunde inte öppna mediefilen — innehållet gick inte att avkoda. avsändaren kan ha en version som paketerar media annorlunda." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியா கோப்பைத் திறக்க முடியவில்லை — தரவு டிகோட் ஆகவில்லை. அனுப்பியவரின் செயலி மீடியாவை வேறு விதமாக அடைக்கக்கூடும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดไฟล์สื่อไม่ได้ — ถอดรหัสข้อมูลไม่สำเร็จ ผู้ส่งอาจใช้แอปเวอร์ชันที่บรรจุสื่อต่างออกไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "medya dosyası açılamadı — veri çözülemedi. gönderen, medyayı farklı paketleyen bir sürüm kullanıyor olabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося відкрити медіафайл — вміст не декодувався. у відправника може бути версія, яка пакує медіа інакше." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا فائل نہیں کھل سکی — پے لوڈ ڈی کوڈ نہیں ہوا۔ بھیجنے والا شاید ایسا ورژن استعمال کر رہا ہے جو میڈیا مختلف طریقے سے پیک کرتا ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không mở được tệp phương tiện — dữ liệu không giải mã được. người gửi có thể dùng phiên bản đóng gói phương tiện khác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开媒体文件 — 数据未能解码。发送方的应用版本打包媒体的方式可能不同。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟媒體檔案 — 資料未能解碼。傳送方的應用版本打包媒體的方式可能不同。" + } + } + } + }, + "media.decode.failure.suppressed" : { + "comment" : "Appended when repeated media decode failures were rate-limited; %lld is how many were suppressed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسائط إضافية تعذّر فك شفرتها: %lld" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আরও মিডিয়া ডিকোড হয়নি: %lld" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "weitere medien nicht dekodiert: %lld" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "additional media failed to decode: %lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "otros archivos no se decodificaron: %lld" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رسانه‌های دیگری رمزگشایی نشد: %lld" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "iba pang media na hindi na-decode: %lld" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "autres médias non décodés : %lld" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פריטי מדיה נוספים שלא פוענחו: %lld" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "और मीडिया डिकोड नहीं हुई: %lld" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "media lain yang gagal didekode: %lld" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "altri media non decodificati: %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デコードできなかったメディア: %lld" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디코딩하지 못한 미디어: %lld" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "media lain yang gagal dinyahkod: %lld" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "थप मिडिया डिकोड भएन: %lld" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "overige media niet gedecodeerd: %lld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "inne pliki nie zdekodowane: %lld" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "outros ficheiros não descodificados: %lld" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "outros arquivos não decodificados: %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ещё не декодировано медиафайлов: %lld" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ytterligare media som inte avkodades: %lld" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "டிகோட் ஆகாத மேலும் மீடியா: %lld" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "สื่ออื่นที่ถอดรหัสไม่สำเร็จ: %lld" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "çözülemeyen diğer medya: %lld" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ще не декодовано медіафайлів: %lld" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مزید میڈیا ڈی کوڈ نہیں ہوئی: %lld" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "phương tiện khác không giải mã được: %lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "另有媒体未能解码:%lld" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "另有媒體未能解碼:%lld" + } + } + } + }, + "media.decode.failure.too_large" : { + "comment" : "System message when an incoming media file is too large", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر حفظ ملف الوسائط — يتجاوز حد الحجم على هذا الجهاز." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া ফাইল সংরক্ষণ করা যায়নি — এটি এই ডিভাইসের আকারসীমা ছাড়িয়ে গেছে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "medien-datei ließ sich nicht speichern — sie überschreitet das größenlimit dieses geräts." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "couldn't save the media file — it exceeds this device's size limit." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo guardar el archivo multimedia — supera el límite de tamaño de este dispositivo." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل رسانه ذخیره نشد — از محدودیت حجم این دستگاه بیشتر است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi ma-save ang media file — lumampas ito sa limitasyon ng laki ng device na ito." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible d'enregistrer le fichier — il dépasse la limite de taille de cet appareil." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לשמור את קובץ המדיה — הוא חורג ממגבלת הגודל של המכשיר." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया फ़ाइल सहेजी नहीं जा सकी — यह इस डिवाइस की आकार सीमा से बड़ी है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa menyimpan berkas media — melebihi batas ukuran perangkat ini." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile salvare il file multimediale — supera il limite di dimensione di questo dispositivo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディアファイルを保存できませんでした — この端末のサイズ上限を超えています。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 파일을 저장할 수 없습니다 — 이 기기의 크기 제한을 넘습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat menyimpan fail media — melebihi had saiz peranti ini." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया फाइल सुरक्षित गर्न सकिएन — यो यस यन्त्रको आकार सीमा नाघ्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kon het mediabestand niet opslaan — het overschrijdt de groottelimiet van dit apparaat." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie udało się zapisać pliku multimedialnego — przekracza limit rozmiaru tego urządzenia." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível guardar o ficheiro de media — excede o limite de tamanho deste dispositivo." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível salvar o arquivo de mídia — excede o limite de tamanho deste dispositivo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось сохранить медиафайл — он превышает ограничение размера на этом устройстве." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunde inte spara mediefilen — den överskrider enhetens storleksgräns." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியா கோப்பைச் சேமிக்க முடியவில்லை — இந்தச் சாதனத்தின் அளவு வரம்பை மீறுகிறது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บันทึกไฟล์สื่อไม่ได้ — เกินขีดจำกัดขนาดของอุปกรณ์นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "medya dosyası kaydedilemedi — bu cihazın boyut sınırını aşıyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося зберегти медіафайл — він перевищує обмеження розміру на цьому пристрої." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا فائل محفوظ نہیں ہو سکی — یہ اس ڈیوائس کی سائز کی حد سے زیادہ ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không lưu được tệp phương tiện — vượt quá giới hạn dung lượng của thiết bị này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法保存媒体文件 — 超出本设备的大小上限。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法儲存媒體檔案 — 超出本裝置的大小上限。" + } + } + } + }, + "media.decode.failure.unsupported_mime" : { + "comment" : "System message when MIME type is unsupported; %@ is the MIME", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر فتح ملف الوسائط — نوع غير مدعوم (%@)." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া ফাইল খোলা যায়নি — অসমর্থিত ধরন (%@)।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "medien-datei ließ sich nicht öffnen — nicht unterstützter typ (%@)." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "couldn't open the media file — unsupported type (%@)." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo abrir el archivo multimedia — tipo no compatible (%@)." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل رسانه باز نشد — نوع پشتیبانی‌نشده (%@)." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi mabuksan ang media file — hindi suportadong uri (%@)." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible d'ouvrir le fichier — type non pris en charge (%@)." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לפתוח את קובץ המדיה — סוג לא נתמך (%@)." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया फ़ाइल नहीं खुली — असमर्थित प्रकार (%@)।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa membuka berkas media — tipe tidak didukung (%@)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile aprire il file multimediale — tipo non supportato (%@)." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディアファイルを開けませんでした — 未対応の形式 (%@)。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 파일을 열 수 없습니다 — 지원하지 않는 형식 (%@)." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat membuka fail media — jenis tidak disokong (%@)." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया फाइल खोल्न सकिएन — असमर्थित प्रकार (%@)।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kon het mediabestand niet openen — niet-ondersteund type (%@)." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie udało się otworzyć pliku multimedialnego — nieobsługiwany typ (%@)." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível abrir o ficheiro de media — tipo não suportado (%@)." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível abrir o arquivo de mídia — tipo não suportado (%@)." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось открыть медиафайл — неподдерживаемый тип (%@)." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunde inte öppna mediefilen — typen stöds inte (%@)." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியா கோப்பைத் திறக்க முடியவில்லை — ஆதரிக்கப்படாத வகை (%@)." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดไฟล์สื่อไม่ได้ — ไม่รองรับประเภทนี้ (%@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "medya dosyası açılamadı — desteklenmeyen tür (%@)." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося відкрити медіафайл — непідтримуваний тип (%@)." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا فائل نہیں کھل سکی — غیر معاون قسم (%@)۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không mở được tệp phương tiện — loại không được hỗ trợ (%@)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开媒体文件 — 不支持的类型 (%@)。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟媒體檔案 — 不支援的類型 (%@)。" + } + } + } + }, "notification.nearby.body_many" : { "comment" : "Body of the nearby notification; placeholder is the peer count", "extractionState" : "manual", diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 608e85c2..b0a5e307 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -165,6 +165,8 @@ protocol BitchatDelegate: AnyObject { // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) + /// Authenticated private media failed local validation on this device (#1518). + func didFailPrivateMediaDecode(from peerID: PeerID, reason: PrivateMediaDecodeFailureReason) } // Provide default implementation to make it effectively optional @@ -192,4 +194,8 @@ extension BitchatDelegate { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } + + func didFailPrivateMediaDecode(from peerID: PeerID, reason: PrivateMediaDecodeFailureReason) { + // Default empty implementation + } } diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index f451411c..28021f85 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -61,6 +61,9 @@ struct BLEFileTransferHandlerEnvironment { _ completion: @escaping () -> Void, _ finalization: @escaping (TransportEventDeliveryOutcome) -> Void ) -> Void + /// Surfaces a local-only system line when authenticated private media + /// fails validation on this device (Android interop debugging, #1518). + let reportPrivateMediaDecodeFailure: (_ reason: PrivateMediaDecodeFailureReason, _ peerID: PeerID) -> Void } /// Process-lifetime reservation cache for stable private-media IDs. @@ -219,17 +222,33 @@ final class BLEFileTransferHandler { case .success(let acceptance): filePacket = acceptance.filePacket mime = acceptance.mime - case .failure(.malformedPayload): - SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return false - case .failure(.payloadTooLarge(let bytes)): - SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return false - case .failure(.unsupportedMime(let mimeType, let bytes)): - SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return false - case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): - SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) + case .failure(let rejection): + if isPrivate, env.isPrivateMediaSenderBlocked(peerID) { + SecureLogger.debug( + "🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before decode failure UX", + category: .security + ) + return true + } + if isPrivate { + let reason = PrivateMediaDecodeFailureReason.from(rejection) + SecureLogger.error( + "❌ Private media decode failed [\(reason.logLabel)] from \(peerID.id.prefix(8))…", + category: .session + ) + env.reportPrivateMediaDecodeFailure(reason, peerID) + } else { + switch rejection { + case .malformedPayload: + SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) + case .payloadTooLarge(let bytes): + SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) + case .unsupportedMime(let mimeType, let bytes): + SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) + case .magicMismatch(let mime, let bytes, let prefixHex): + SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) + } + } return false } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5f9b97af..ac4dfb27 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2633,6 +2633,11 @@ final class BLEService: NSObject { completion: completion, finalization: finalization ) + }, + reportPrivateMediaDecodeFailure: { [weak self] reason, peerID in + DispatchQueue.main.async { + self?.delegate?.didFailPrivateMediaDecode(from: peerID, reason: reason) + } } ) } diff --git a/bitchat/Services/PrivateMediaDecodeFailureReason.swift b/bitchat/Services/PrivateMediaDecodeFailureReason.swift new file mode 100644 index 00000000..12d2daf1 --- /dev/null +++ b/bitchat/Services/PrivateMediaDecodeFailureReason.swift @@ -0,0 +1,80 @@ +// +// PrivateMediaDecodeFailureReason.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// People-visible reasons when authenticated private media fails locally (#1518). +enum PrivateMediaDecodeFailureReason: Equatable { + case malformedPayload + case payloadTooLarge(bytes: Int) + case unsupportedMime(mimeType: String?) + case magicMismatch(mime: String, prefixHex: String) + + var logLabel: String { + switch self { + case .malformedPayload: + return "malformed_payload" + case .payloadTooLarge(let bytes): + return "payload_too_large:\(bytes)" + case .unsupportedMime(let mimeType): + return "unsupported_mime:\(mimeType ?? "")" + case .magicMismatch(let mime, let prefixHex): + return "magic_mismatch:\(mime):\(prefixHex)" + } + } + + var localizedSystemMessage: String { + switch self { + case .malformedPayload: + return String( + localized: "media.decode.failure.malformed", + defaultValue: "couldn't open the media file — the payload didn't decode. the sender may be on an app version that packs media differently.", + comment: "System message when a private media payload fails to decode" + ) + case .payloadTooLarge: + return String( + localized: "media.decode.failure.too_large", + defaultValue: "couldn't save the media file — it exceeds this device's size limit.", + comment: "System message when an incoming media file is too large" + ) + case .unsupportedMime(let mimeType): + return String( + format: String( + localized: "media.decode.failure.unsupported_mime", + defaultValue: "couldn't open the media file — unsupported type (%@).", + comment: "System message when MIME type is unsupported; %@ is the MIME" + ), + locale: .current, + mimeType ?? "unknown" + ) + case .magicMismatch(let mime, _): + return String( + format: String( + localized: "media.decode.failure.magic_mismatch", + defaultValue: "couldn't open the media file — contents don't match the declared type (%@).", + comment: "System message when file magic bytes mismatch MIME; %@ is the MIME" + ), + locale: .current, + mime + ) + } + } + + static func from(_ failure: BLEIncomingFileRejection) -> PrivateMediaDecodeFailureReason { + switch failure { + case .malformedPayload: + return .malformedPayload + case .payloadTooLarge(let bytes): + return .payloadTooLarge(bytes: bytes) + case .unsupportedMime(let mimeType, _): + return .unsupportedMime(mimeType: mimeType) + case .magicMismatch(let mime, _, let prefixHex): + return .magicMismatch(mime: mime.mimeString, prefixHex: prefixHex) + } + } +} diff --git a/bitchat/Services/PrivateMediaDecodeFailureThrottle.swift b/bitchat/Services/PrivateMediaDecodeFailureThrottle.swift new file mode 100644 index 00000000..fb46c590 --- /dev/null +++ b/bitchat/Services/PrivateMediaDecodeFailureThrottle.swift @@ -0,0 +1,74 @@ +// +// PrivateMediaDecodeFailureThrottle.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Rate-limits the system lines posted when authenticated private media fails +/// to decode (#1518). +/// +/// Decode failures are attacker-triggerable: an authenticated peer can send +/// malformed media as fast as the link allows, and one system line per payload +/// would let them flood the DM thread. At most one line is posted per peer per +/// window; failures suppressed inside a window are counted and reported once +/// on the next line rather than dropped silently. +struct PrivateMediaDecodeFailureThrottle { + enum Outcome: Equatable { + /// Post the reason on its own — nothing was suppressed beforehand. + case post(PrivateMediaDecodeFailureReason) + /// Post the reason plus a count of failures suppressed since the last line. + case postWithSuppressed(PrivateMediaDecodeFailureReason, suppressed: Int) + /// Inside an open window: count it, say nothing. + case suppress + } + + /// One line per peer per window. Five minutes keeps a genuinely broken + /// sender legible without giving a hostile one a usable flood rate. + static let defaultWindow: TimeInterval = 300 + + private struct Window { + var openedAt: Date + var suppressed: Int + } + + private let window: TimeInterval + private var windows: [PeerID: Window] = [:] + + init(window: TimeInterval = PrivateMediaDecodeFailureThrottle.defaultWindow) { + self.window = window + } + + /// Records a failure and decides whether it should surface. + mutating func record( + from peerID: PeerID, + reason: PrivateMediaDecodeFailureReason, + now: Date = Date() + ) -> Outcome { + prune(before: now) + + if var open = windows[peerID], now.timeIntervalSince(open.openedAt) < window { + open.suppressed += 1 + windows[peerID] = open + return .suppress + } + + let suppressed = windows[peerID]?.suppressed ?? 0 + windows[peerID] = Window(openedAt: now, suppressed: 0) + return suppressed > 0 + ? .postWithSuppressed(reason, suppressed: suppressed) + : .post(reason) + } + + /// Drops peers whose window closed long enough ago that their suppressed + /// count is no longer worth reporting. Without this the map would grow with + /// every rotated peer ID a hostile sender presents. + private mutating func prune(before now: Date) { + let deadline = now.addingTimeInterval(-window * 2) + windows = windows.filter { $0.value.openedAt > deadline } + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..f5a3b453 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -202,6 +202,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self) private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) + /// Rate limit for the media-decode-failure system lines. Decode failures are + /// attacker-triggerable, so the thread is protected from a flood (#1518). + private var privateMediaDecodeFailureThrottle = PrivateMediaDecodeFailureThrottle() private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) @@ -2144,6 +2147,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage ) } + func didFailPrivateMediaDecode(from peerID: PeerID, reason: PrivateMediaDecodeFailureReason) { + // Attacker-triggerable: an authenticated peer can send malformed media + // as fast as the link allows, so this is rate-limited per peer rather + // than posting a line per payload. + switch privateMediaDecodeFailureThrottle.record(from: peerID, reason: reason) { + case .suppress: + return + case .post(let reason): + addLocalPrivateSystemMessage(reason.localizedSystemMessage, to: peerID) + case .postWithSuppressed(let reason, let suppressed): + let summary = String( + format: String( + localized: "media.decode.failure.suppressed", + defaultValue: "additional media failed to decode: %lld", + comment: "Appended when repeated media decode failures were rate-limited; %lld is how many were suppressed" + ), + locale: .current, + suppressed + ) + addLocalPrivateSystemMessage("\(reason.localizedSystemMessage) \(summary)", to: peerID) + } + } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { Task { @MainActor [weak self] in self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp) diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 835bcdba..32f2019c 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -24,6 +24,7 @@ struct BLEFileTransferHandlerTests { var finishedIncomingFileDeliveries: [URL] = [] var lastSeenUpdates: [PeerID] = [] var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] + var decodeFailures: [(PrivateMediaDecodeFailureReason, PeerID)] = [] var deliveredMessages: [BitchatMessage] = [] var shouldAcceptDelivery = true var deliveryOutcome = TransportEventDeliveryOutcome.accepted @@ -188,6 +189,9 @@ struct BLEFileTransferHandlerTests { } outcome = .accepted completion() + }, + reportPrivateMediaDecodeFailure: { reason, peerID in + recorder.decodeFailures.append((reason, peerID)) } ) return BLEFileTransferHandler(environment: environment) @@ -929,6 +933,21 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveryAcks.count == 1) } + @Test + func blockedPrivateMediaDecodeFailureDoesNotPostSystemLine() { + let recorder = Recorder() + recorder.blockedPeers = [remotePeerID] + let handler = makeHandler(recorder: recorder) + let payload = Data([0x00]) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.decodeFailures.isEmpty) + } + @Test func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() { let recorder = Recorder() diff --git a/bitchatTests/Services/PrivateMediaDecodeFailureReasonTests.swift b/bitchatTests/Services/PrivateMediaDecodeFailureReasonTests.swift new file mode 100644 index 00000000..c39f1ee0 --- /dev/null +++ b/bitchatTests/Services/PrivateMediaDecodeFailureReasonTests.swift @@ -0,0 +1,26 @@ +// +// PrivateMediaDecodeFailureReasonTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +@testable import bitchat + +struct PrivateMediaDecodeFailureReasonTests { + @Test func mapsMalformedRejection() { + let reason = PrivateMediaDecodeFailureReason.from(.malformedPayload) + #expect(reason.logLabel == "malformed_payload") + #expect(!reason.localizedSystemMessage.isEmpty) + } + + @Test func mapsMagicMismatchWithMimeRawValue() { + let reason = PrivateMediaDecodeFailureReason.from( + .magicMismatch(mime: .jpeg, bytes: 12, prefixHex: "ff d8") + ) + #expect(reason.logLabel.contains("magic_mismatch")) + #expect(reason.localizedSystemMessage.contains("jpeg") || reason.localizedSystemMessage.contains("image")) + } +} diff --git a/bitchatTests/Services/PrivateMediaDecodeFailureThrottleTests.swift b/bitchatTests/Services/PrivateMediaDecodeFailureThrottleTests.swift new file mode 100644 index 00000000..41cd01c4 --- /dev/null +++ b/bitchatTests/Services/PrivateMediaDecodeFailureThrottleTests.swift @@ -0,0 +1,100 @@ +// +// PrivateMediaDecodeFailureThrottleTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// Media decode failures are attacker-triggerable: an authenticated peer can +/// send malformed payloads as fast as the link allows. Without a rate limit, +/// one system line per payload floods the DM thread (#1518). +struct PrivateMediaDecodeFailureThrottleTests { + private let alice = PeerID(str: "aaaaaaaaaaaaaaaa") + private let mallory = PeerID(str: "bbbbbbbbbbbbbbbb") + private let t0 = Date(timeIntervalSince1970: 1_000_000) + + private func reason() -> PrivateMediaDecodeFailureReason { .malformedPayload } + + @Test("The first failure from a peer posts immediately") + func firstFailurePosts() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + #expect(throttle.record(from: alice, reason: reason(), now: t0) == .post(.malformedPayload)) + } + + @Test("A flood inside one window posts exactly one line") + func floodPostsOnlyOnce() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + var posted = 0 + for i in 0..<500 { + let outcome = throttle.record( + from: mallory, + reason: reason(), + now: t0.addingTimeInterval(Double(i) * 0.1) // 500 payloads in 50s + ) + if outcome != .suppress { posted += 1 } + } + #expect(posted == 1) + } + + @Test("Suppressed failures are reported on the next line, not dropped") + func suppressedCountSurfacesOnNextLine() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + _ = throttle.record(from: mallory, reason: reason(), now: t0) + for i in 1...9 { + _ = throttle.record(from: mallory, reason: reason(), now: t0.addingTimeInterval(Double(i))) + } + let afterWindow = throttle.record( + from: mallory, + reason: reason(), + now: t0.addingTimeInterval(301) + ) + #expect(afterWindow == .postWithSuppressed(.malformedPayload, suppressed: 9)) + } + + @Test("A quiet peer posts plainly, with no suppressed count") + func quietPeerPostsWithoutCount() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + _ = throttle.record(from: alice, reason: reason(), now: t0) + let next = throttle.record(from: alice, reason: reason(), now: t0.addingTimeInterval(600)) + #expect(next == .post(.malformedPayload)) + } + + @Test("Peers are throttled independently") + func peersAreIndependent() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + #expect(throttle.record(from: alice, reason: reason(), now: t0) == .post(.malformedPayload)) + // Mallory flooding must not silence a genuine failure from Alice. + #expect(throttle.record(from: mallory, reason: reason(), now: t0) == .post(.malformedPayload)) + #expect(throttle.record(from: alice, reason: reason(), now: t0.addingTimeInterval(1)) == .suppress) + #expect(throttle.record(from: mallory, reason: reason(), now: t0.addingTimeInterval(1)) == .suppress) + } + + @Test("The reason carried through is the one that surfaced") + func outcomeCarriesTheReason() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + let tooLarge = PrivateMediaDecodeFailureReason.payloadTooLarge(bytes: 99) + #expect(throttle.record(from: alice, reason: tooLarge, now: t0) == .post(tooLarge)) + } + + /// A hostile sender rotating peer IDs must not grow the throttle's state + /// without bound. + @Test("State does not accumulate across long-idle peers") + func stalePeersArePruned() { + var throttle = PrivateMediaDecodeFailureThrottle(window: 300) + for i in 0..<1_000 { + let rotating = PeerID(str: String(format: "%016x", i)) + _ = throttle.record(from: rotating, reason: reason(), now: t0) + } + // Long after every window closed, an old peer starts a fresh window + // rather than reporting a stale suppressed count. + let old = PeerID(str: String(format: "%016x", 0)) + let outcome = throttle.record(from: old, reason: reason(), now: t0.addingTimeInterval(10_000)) + #expect(outcome == .post(.malformedPayload)) + } +} diff --git a/docs/PRIVATE-MEDIA-DECODE-FAILURES.md b/docs/PRIVATE-MEDIA-DECODE-FAILURES.md new file mode 100644 index 00000000..463947be --- /dev/null +++ b/docs/PRIVATE-MEDIA-DECODE-FAILURES.md @@ -0,0 +1,22 @@ +# Private media decode failures (#1518) + +When authenticated private media fails local validation, bitchat now: + +1. Logs a structured reason code (`PrivateMediaDecodeFailureReason.logLabel`). +2. Posts a **system line in the affected DM** with a people-readable explanation. + +Public mesh file transfers keep the existing security logging path without spamming the mesh timeline. + +## Rate limiting + +Decode failures are attacker-triggerable: an authenticated peer can send malformed media as fast as the link allows, and one system line per payload would let them flood a DM thread. + +`PrivateMediaDecodeFailureThrottle` posts at most one line per peer per five-minute window. Failures suppressed inside a window are counted, not dropped — the next line that surfaces reports how many were withheld. Peers are throttled independently, so a hostile sender cannot silence a genuine failure from someone else, and windows that closed long ago are pruned so a sender rotating peer IDs cannot grow the throttle's state without bound. + +## Blocked senders + +A decode failure from a blocked peer is swallowed: no system line is posted, and `handlePrivatePayload` returns `true` where every other failure returns `false`. + +The return value distinguishes "consumed, deliberately silent" from "could not handle this". A blocked sender's payload is not a failure to handle — it is a drop we chose, and blocking should produce no local UI. Marking it `false` would file a deliberate policy drop alongside genuine decode errors. + +This is currently a statement of intent rather than a behavior change: `handlePrivatePayload` is `@discardableResult` and its only production caller (`BLEService`, the `.privateFile` branch of `deliverNoisePayload`) ignores the result. Today the difference is observable only to `BLEFileTransferHandlerTests`. It matters if a caller ever starts branching on it.