mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
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:
parent
9b84b36122
commit
1fd64e9913
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
80
bitchat/Services/PrivateMediaDecodeFailureReason.swift
Normal file
80
bitchat/Services/PrivateMediaDecodeFailureReason.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
bitchat/Services/PrivateMediaDecodeFailureThrottle.swift
Normal file
74
bitchat/Services/PrivateMediaDecodeFailureThrottle.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
22
docs/PRIVATE-MEDIA-DECODE-FAILURES.md
Normal file
22
docs/PRIVATE-MEDIA-DECODE-FAILURES.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user