feat: surface private media decode failures in the DM thread

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
This commit is contained in:
Taksh 2026-08-11 20:58:57 +05:30
parent 9b84b36122
commit 1fd64e9913
11 changed files with 1318 additions and 11 deletions

View File

@ -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",

View File

@ -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
}
}

View File

@ -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 ?? "<empty>")' 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 ?? "<empty>")' 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
}

View File

@ -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)
}
}
)
}

View File

@ -0,0 +1,80 @@
//
// PrivateMediaDecodeFailureReason.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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 ?? "<empty>")"
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)
}
}
}

View File

@ -0,0 +1,74 @@
//
// PrivateMediaDecodeFailureThrottle.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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 }
}
}

View File

@ -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)

View File

@ -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()

View File

@ -0,0 +1,26 @@
//
// PrivateMediaDecodeFailureReasonTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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"))
}
}

View File

@ -0,0 +1,100 @@
//
// PrivateMediaDecodeFailureThrottleTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// 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))
}
}

View File

@ -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.