Article technique

Vous ne pouvez pas corriger silencieusement un PDF chiffré en Delphi

Prenez un PDF de facture qui porte déjà un chiffrement AES-256 et demandez au composant PDFium pour Delphi et C++Builder (PDFiumPas) de le tamponner PDF/A pour conservation archivistique, ou de le signer avec PAdES, via une mise à jour incrémentale plutôt qu'une réécriture complète. La bibliothèque n'y parviendra pas en corrigeant directement les octets chiffrés : ses six injecteurs de marqueurs de conformité détectent une entrée /Encrypt existante et laissent passer la source octet pour octet sans modification, et son signataire PAdES lève une exception plutôt que d'émettre une signature qu'aucun validateur n'acceptera

C'est une question différente de l'audit d'un PDF que vous n'avez pas créé pour y déceler un risque caché, qui est son propre exercice en lecture seule. Cet article porte sur le côté écriture de la même frontière de confiance : ce que votre propre code est autorisé à faire à un fichier dont les octets sont déjà verrouillés derrière le mot de passe de quelqu'un d'autre, au moment où ce code tente d'y ajouter quoi que ce soit après coup

Qu'exige ISO 32000-1 lorsque vous mettez à jour un PDF chiffré ?

ISO 32000-1 §7.5.6 exige que le trailer d'une mise à jour incrémentale répète chaque entrée du trailer précédent sauf /Prev, et le Tableau 15 liste /Encrypt parmi les entrées qu'un trailer peut porter. Retirez-la du nouveau trailer et un lecteur conforme n'a aucune raison de douter de l'omission : le trailer le plus récent fait autorité, si bien qu'un lecteur qui n'y trouve pas de /Encrypt décide que le fichier entier n'est pas chiffré et tente d'analyser le corps plus ancien, toujours chiffré, comme des octets ordinaires. Gardez /Encrypt dans le nouveau trailer mais écrivez les propres objets de la mise à jour en clair, et l'échec se déplace simplement d'une étape : le lecteur détecte correctement le chiffrement, fait passer chaque objet qu'il touche à travers le chiffreur du fichier, y compris les nouveaux qui n'ont jamais été chiffrés en premier lieu, et récupère du bruit pour du contenu qui était parfaitement lisible avant que le déchiffrement ne le touche. L'une ou l'autre erreur produit un fichier qui ressemble à une mise à jour incrémentale normale et bien formée au niveau des octets, jusqu'à ce qu'un lecteur conforme l'ouvre

Six injecteurs de marqueurs, un seul portail de chiffrement v2.14.2

PDFiumPas fournit six injecteurs de marqueurs au niveau des octets, un pour chaque sous-ensemble PDF ISO qu'il peut étiqueter : PDF/A (ISO 19005), PDF/X (ISO 15930), PDF/UA (ISO 14289-1), PDF/E-1 (ISO 24517-1), PDF/R-1 (ISO 23504-1), et PDF/VT-1 (ISO 16612-2). Chacun prend les octets que le propre FPDF_SaveAsCopy de PDFium a déjà écrits et superpose une seconde mise à jour incrémentale, plus petite, par-dessus : un nouveau flux de métadonnées XMP, une édition du dictionnaire de catalogue qui pointe vers lui, et pour les sous-ensembles orientés impression un OutputIntent et un profil ICC. Depuis la v2.14.2, chacun de InjectPdfAMarkers, InjectPdfXMarkers, InjectPdfUaMarkers, InjectPdfEMarkers, InjectPdfRMarkers, et InjectPdfVTMarkers lit d'abord le trailer source, et s'il signale une entrée /Encrypt existante, copie la source vers le flux de destination sans modification et revient immédiatement. Aucun XMP, aucun OutputIntent, aucune édition de catalogue — l'appelant récupère le fichier original, octet pour octet

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf is byte-identical to the source: still encrypted,
    // no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
    // nothing was corrupted either
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Autorisé à être chiffré n'est pas la même chose que sûr à injecter

PDF/E-1 et PDF/R-1 autorisent tous deux explicitement que leur document hôte soit chiffré au niveau de la spécification, ce qui se lit comme une exemption jusqu'à ce qu'on regarde ce qui doit réellement se passer sur disque. ISO 24517-1 §6.3 autorise le chiffrement pour PDF/E-1, et ISO 23504-1 §6.2.3 l'autorise pour PDF/R-1 à condition que l'en-tête déclare %PDF-2.0. Aucune de ces clauses ne dit quoi que ce soit sur le fait qu'un post-processeur au niveau des octets puisse ajouter en toute sécurité un objet en clair à ce conteneur chiffré, et il ne le peut pas, pour les mêmes raisons du §7.5.6 qui s'appliquent à tout autre sous-ensemble. Les propres validateurs de conformité de PDFiumPas pour ces deux profils, ValidatePdfECompliance et ValidatePdfRCompliance, enregistrent la présence de /Encrypt délibérément sans la signaler comme un défaut, ce qui est correct pour un validateur en lecture seule qui n'écrit jamais un octet. C'est aussi un schéma facile à survoler en supposant que l'injecteur jumeau n'a besoin d'aucune garde séparée, alors que l'injecteur est la fonction de la paire qui doit réellement refuser

SaveAsPdfX déchiffre-t-il silencieusement votre document ?

Oui, chaque fois que vous passez par les méthodes de commodité publiques plutôt que d'appeler directement un injecteur. Chacune de TPdf.SaveAsPdfA, SaveAsPdfX, SaveAsPdfUa, SaveAsPdfE, SaveAsPdfR, et SaveAsPdfVT rend le document actuel vers un flux temporaire avec SaveAs(Tmp, saRemoveSecurity) avant de transmettre ces octets à son injecteur correspondant. saRemoveSecurity correspond au propre drapeau FPDF_REMOVE_SECURITY de PDFium, si bien que la copie temporaire que l'injecteur reçoit n'a jamais été chiffrée en premier lieu, et la garde /Encrypt de l'injecteur n'a jamais de raison de se déclencher. La sortie porte vos marqueurs PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1, ou PDF/VT-1, mais elle n'est plus protégée par quel que soit le mot de passe qui ouvrait la source

Ce compromis est invisible jusqu'à ce que quelqu'un en aval ouvre la copie archivistique « protégée » sans mot de passe et remarque que cela fonctionne simplement. La correction n'est pas un appel de méthode différent ; PDFiumPas n'a pas de contrepartie saAddSecurity à apparier avec saRemoveSecurity, car le moteur PDFium sous-jacent n'a jamais été construit pour écrire un nouveau chiffrement, seulement pour le retirer. Si les deux propriétés comptent pour un même fichier, le chiffrement doit être une étape séparée que vous possédez, appliquée après les marqueurs de conformité, pas intégrée dans le même appel SaveAsPdfA

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // needed to open the source at all
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
    // ran first inside SaveAsPdfA: the output opens without a password
  finally
    Pdf.Free;
  end;
end;

Que se passe-t-il lorsque vous signez un PDF chiffré avec PAdES ?

PDFiumPas refuse purement et simplement, plutôt que d'abandonner silencieusement la demande comme le fait un injecteur de marqueurs. TPdf.SignPades et SignPadesToStream passent tous deux par un SignPadesBytes interne, et la première chose qu'il fait après avoir analysé le trailer source est de vérifier la présence de /Encrypt. Si l'entrée est présente, il lève EPadesCrypto avec le message « SignPadesBytes: the source document is encrypted; remove encryption before signing » au lieu de continuer plus loin. InjectPadesDssMarkers, la fonction qui intègre les certificats, les réponses OCSP, et les CRL pour la validation à long terme, applique la vérification identique pour la raison identique, avec son propre message : « InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material »

Le raisonnement ici est plus strict que le passage silencieux des injecteurs de marqueurs, et délibérément ainsi. Un passage silencieux est sûr pour un tampon PDF/A car le sauter vous laisse avec le même PDF valide que celui de départ, simplement non étiqueté. La signature ne peut pas échouer aussi silencieusement : une signature qui n'a silencieusement jamais été ajoutée ressemble, pour tout code appelant qui ne vérifie qu'un résultat booléen, exactement à une signature ajoutée avec succès. EPadesCrypto descend de la classe Exception ordinaire, si bien que l'intercepter est une gestion d'exception normale, pas une convention de flux de contrôle spéciale à apprendre

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

Ordonner les tampons de conformité, les signatures, et le chiffrement

La correction pratique est une question d'ordre, pas une bibliothèque différente. Appliquez d'abord les marqueurs PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1, ou PDF/VT-1, ajoutez ensuite toute signature PAdES, et ce n'est qu'ensuite qu'exécuter quelle que soit l'étape de votre pipeline qui possède réellement le chiffrement, que ce soit un écrivain PDF dédié, un appareil de signature, ou votre propre implémentation AES. La couche de mise à jour incrémentale de PDFiumPas s'insère naturellement au milieu de cette séquence, ajoutant de petits objets ciblés sur un fichier par ailleurs terminé, et le chiffrement appartient à la fin précisément parce que c'est la seule opération de la chaîne que PDFiumPas lui-même ne peut ni effectuer ni inverser

Rien de tout cela ne change la façon dont PDFiumPas lit le trailer et les données de référence croisée dont dépend chaque mise à jour incrémentale, ce qui est sa propre source de subtilité une fois que les flux xref entrent en jeu ; la validation des flux d'objets et de référence croisée d'un PDF couvre comment ce même chemin de lecture de trailer gère les structures compressées PDF 1.5+. Et une fois qu'un document est prêt pour quelque chose de plus fort qu'un tampon de conformité, signer un PDF avec une signature PAdES B-B en Delphi est là où SignPades prend le relais exactement là où cet article s'arrête

Les injecteurs de marqueurs et les méthodes SignPades décrits ici font partie du composant PDFium pour Delphi et C++Builder, aux côtés du rendu et de l'inspection en lecture seule que PDFium fournit nativement