Sfoglia il codice sorgente

PDF : un PDF chiffré en AES sans mot de passe d'ouverture est lu

Sans la bibliothèque cryptography, pypdf lève DependencyError dès le
constructeur de PdfReader pour un PDF AES (V5/R6), avant decrypt("").
Le except général en faisait « Je n'arrive pas à lire ce PDF. »,
journalisé en INFO : un PDF seulement protégé contre la modification
était refusé comme illisible.

cryptography est ajouté à requirements.txt. Si elle manque quand même,
prepare_pdf intercepte DependencyError à part : message « protégé » pour
le membre et WARNING avec la cause pour l'administrateur.

Vérifié sur le bot local : Claude lit le contenu d'un PDF AES-256 envoyé
encore chiffré.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 giorno fa
parent
commit
e5d9110547
5 ha cambiato i file con 26 aggiunte e 4 eliminazioni
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 4 0
      bot/pdf_files.py
  4. 1 0
      requirements.txt
  5. 18 3
      tests/test_attachments_prep.py

+ 2 - 0
CHANGELOG.md

@@ -16,6 +16,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Plusieurs gros fichiers envoyés pendant que Rika écrit ne font plus échouer sa réponse.** Ils partaient tous dans un seul appel à Claude, qui pouvait dépasser ses limites. Quand leurs PDF et images sont trop lourds ensemble (plus de 18 Mo, ou plus de 100 pages et images), Rika répond maintenant en plusieurs fois, dans l'ordre, chaque réponse citant le message auquel elle répond.
 
+- **Les PDF protégés seulement contre la modification sont lus.** Un PDF chiffré en AES sans mot de passe d'ouverture (courant avec Acrobat ou LibreOffice) était refusé comme « illisible ». Il est maintenant lu, grâce à la nouvelle dépendance `cryptography` installée avec les autres.
+
 ---
 
 ## Version 5.12

+ 1 - 1
README.md

@@ -20,7 +20,7 @@ Le bot parle français. Sa façon de parler, son caractère et son nom sont ceux
 Dans son salon comme dans les mentions, le bot lit jusqu'à 4 images ou un PDF, et un fichier texte, par message :
 
 - **Images** (JPEG, PNG, GIF, WebP, BMP, TIFF) : réduites et converties automatiquement si besoin, 40 millions de pixels au plus (120 millions pour la plupart des photos JPEG). Avec plusieurs images, il peut les comparer ou parler de « la deuxième ».
-- **PDF** : texte, tableaux, schémas et pages scannées, 20 pages (`PDF_MAX_PAGES`) et 20 Mo au plus. Les PDF protégés par un mot de passe sont refusés.
+- **PDF** : texte, tableaux, schémas et pages scannées, 20 pages (`PDF_MAX_PAGES`) et 20 Mo au plus. Les PDF protégés par un mot de passe d'ouverture sont refusés ; ceux protégés seulement contre la modification ou l'impression sont lus.
 - **Fichiers texte** (`.txt`, `.md`, `.csv`, `.json`, `.log`, sous-titres, configuration, HTML, code…), 100 Ko au plus.
 
 Seul le nom du fichier reste ensuite en mémoire : le bot se souvient qu'il l'a lu, sans en repayer le contenu à chaque message.

+ 4 - 0
bot/pdf_files.py

@@ -6,6 +6,7 @@ import io
 import logging
 
 from pypdf import PdfReader
+from pypdf.errors import DependencyError
 
 from .errors import UserFacingError
 from .media import PDF_MEDIA_TYPE, ImageData
@@ -36,6 +37,9 @@ def prepare_pdf(name: str, data: bytes, max_pages: int) -> ImageData:
         pages = len(reader.pages)
     except PdfError:
         raise
+    except DependencyError as exc:  # chiffrement AES sans la bibliothèque cryptography : à corriger côté serveur
+        logger.warning("PDF %s chiffré illisible faute de bibliothèque de déchiffrement : %s", name, exc)
+        raise PdfError("Ce PDF est protégé et je ne peux pas l'ouvrir pour le moment.")
     except Exception as exc:  # fichier abîmé ou pas un PDF : pypdf lève des erreurs très variées
         logger.info("PDF %s illisible : %s", name, exc)
         raise PdfError("Je n'arrive pas à lire ce PDF.")

+ 1 - 0
requirements.txt

@@ -4,3 +4,4 @@ python-dotenv>=1.0
 tzdata>=2024.1
 Pillow>=10.0
 pypdf>=5.0
+cryptography>=3.1  # PDF chiffrés en AES (pypdf)

+ 18 - 3
tests/test_attachments_prep.py

@@ -16,7 +16,7 @@ import aiohttp
 from PIL import Image
 from pypdf import PdfWriter
 
-from bot import attachments, images
+from bot import attachments, images, pdf_files
 from bot.attachments import AttachmentReader
 from bot.images import MAX_DIMENSION, MAX_PIXELS, ImageError, prepare_image
 from bot.media import ImageData
@@ -255,12 +255,13 @@ class DownloadFailureTest(unittest.IsolatedAsyncioTestCase):
                 await self.reader.download_image(self.file("lente.png", never_ends))
 
 
-def pdf(pages: int, password: str | None = None, owner_password: str | None = None) -> bytes:
+def pdf(pages: int, password: str | None = None, owner_password: str | None = None,
+        algorithm: str | None = None) -> bytes:
     writer = PdfWriter()
     for _ in range(pages):
         writer.add_blank_page(width=200, height=200)
     if password is not None:
-        writer.encrypt(user_password=password, owner_password=owner_password or "propriétaire")
+        writer.encrypt(user_password=password, owner_password=owner_password or "propriétaire", algorithm=algorithm)
     buffer = io.BytesIO()
     writer.write(buffer)
     return buffer.getvalue()
@@ -295,6 +296,20 @@ class PreparePdfTest(unittest.TestCase):
         # Chiffré sans mot de passe d'ouverture (seulement des droits limités) : lisible.
         self.assertTrue(prepare_pdf("protégé.pdf", pdf(1, password=""), max_pages=3).is_pdf)
 
+    def test_owner_password_only_aes(self):
+        # Chiffrement AES-256 (Acrobat, LibreOffice récents) : pypdf a besoin de la bibliothèque cryptography.
+        data = pdf(2, password="", owner_password="propriétaire", algorithm="AES-256")
+        self.assertEqual(prepare_pdf("protégé.pdf", data, max_pages=3).pages, 2)
+
+    def test_missing_decryption_library(self):
+        # Sans bibliothèque de déchiffrement, pypdf refuse le PDF : le dire clairement, et le signaler à l'administrateur.
+        error = pdf_files.DependencyError("cryptography>=3.1 is required for AES algorithm")
+        with mock.patch.object(pdf_files, "PdfReader", side_effect=error), \
+                self.assertLogs("chatbot.pdf", "WARNING") as logs, self.assertRaises(PdfError) as ctx:
+            prepare_pdf("protégé.pdf", b"%PDF", max_pages=3)
+        self.assertIn("protégé", ctx.exception.user_message)
+        self.assertIn("cryptography", logs.output[0])
+
     def test_not_a_pdf(self):
         for data in (b"", b"bonjour, ceci n'est pas un PDF", encode(Image.new("RGB", (50, 50)), "PNG")):
             with self.subTest(data=data[:10]), self.assertLogs("chatbot.pdf", "INFO"), \