Article technique

Chiffrement PDF par certificats en Delphi : RSA-OAEP et ECDH

HotPDF chiffre un PDF pour des titulaires de certificats précis via le gestionnaire de sécurité à clé publique d'ISO 32000 : EnablePubKeyEncryption prend une graine aléatoire de 20 octets, et chaque destinataire reçoit sa propre enveloppe CMS, construite par AddPubKeyRecipientCertificate pour les clés RSA (transport de clé RSA-OAEP) ou AddPubKeyAgreementRecipientWithSecret pour les clés à courbes elliptiques (ECDH sur P-256, P-384, P-521, X25519 ou X448). Personne ne partage de mot de passe ; celui qui détient la clé privée correspondante ouvre le fichier

Le cas d'usage est toujours une variation de la même histoire. Un dossier d'audit trimestriel part vers trois réviseurs externes, le juridique veut que chacun le lise, un seul a le droit de l'imprimer, et personne ne veut un mot de passe qui traîne dans un fil de courriel à côté de la pièce jointe. Le chiffrement par mot de passe ne sait pas dire cela. Le chiffrement par certificats, si, parce que chaque destinataire déverrouille le document avec une clé qu'il détient déjà, et chaque destinataire peut porter un jeu de permissions différent dans sa propre enveloppe

En quoi le chiffrement PDF par certificats diffère-t-il d'un mot de passe ?

Un PDF chiffré à clé publique dérive sa clé de fichier d'une graine aléatoire plus les octets exacts de chaque enveloppe de destinataire, pas de quoi que ce soit qu'une personne tape. Le gestionnaire est décrit dans ISO 32000-1 §7.6.4 (§7.6.5 dans ISO 32000-2), et les enveloppes sont des structures CMS EnvelopedData au sens du RFC 5652. HotPDF écrit /Filter /Adobe.PubSec avec /SubFilter /adbe.pkcs7.s5 ; pour AES-256 cela veut dire /V 5 et une entrée /DefaultCryptFilter sous /CF avec /CFM /AESV3, et le tableau /Recipients vit dans ce crypt filter. Chaque enveloppe chiffre 24 octets : la graine de 20 octets suivie du mot de permissions 32 bits de ce destinataire. La valeur /P du dictionnaire de chiffrement n'est qu'un placeholder, parce que les vraies permissions voyagent dans chaque enveloppe. Au chargement, un lecteur déballe une enveloppe, récupère la graine, et hache la graine avec chaque enveloppe dans l'ordre de /Recipients (SHA-256 pour AES-256, SHA-1 pour les chiffreurs plus anciens) pour reconstruire la clé de fichier. Si vous hésitez encore entre ce modèle et des mots de passe ordinaires, le guide du chiffrement par mot de passe AES-256 et des drapeaux de permissions couvre l'autre versant de cet arbitrage

Schéma du chiffrement à clé publique HotPDF : EnablePubKeyEncryption fixe une graine de 20 octets, chaque enveloppe CMS EnvelopedData chiffre ces 20 octets plus un mot de permissions 32 bits sous /Filter /Adobe.PubSec avec /SubFilter /adbe.pkcs7.s5 et /CFM /AESV3, et le lecteur déballe une enveloppe, récupère la graine et la hache avec chaque entrée /Recipients dans l'ordre du tableau pour reconstruire la clé de fichier
La valeur /P du dictionnaire de chiffrement n'est qu'un placeholder parce que les vraies permissions voyagent dans chaque enveloppe, et rien en aval ne peut réordonner ou réencoder le tableau sur lequel court le condensat

Écrire des destinataires RSA avec EnablePubKeyEncryption

Pour des certificats RSA, appelez EnablePubKeyEncryption avec aes256, puis appelez AddPubKeyRecipientCertificate une fois par certificat encodé en DER avant BeginDoc. Le helper construit une enveloppe RSAES-OAEP dans le processus avec des valeurs THPDFRSAOAEPHash pour le condensé OAEP et le condensé MGF1 (rohSHA256, rohSHA384 ou rohSHA512), et il chiffre le contenu de l'enveloppe en AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // exactement 20 octets, même pour AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // le type de clé par défaut est aes128
    // Le réviseur A peut imprimer ; le réviseur B ne peut que lire et extraire
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Trois détails de ce listing portent la charge. D'abord, la longueur de la graine est fixée à 20 octets pour tout type de clé, AES-256 compris ; EnablePubKeyEncryption lève sur toute autre longueur. Ensuite, EnablePubKeyEncryption vaut aes128 par défaut, et les deux helpers de certificats refusent de tourner tant que le type de clé n'est pas aes256, donc oublier le second argument vous vaut l'exception « certificate envelopes require aes256 ». Les chiffreurs historiques (k40, k128, aes128) fonctionnent toujours, mais uniquement via AddPubKeyRecipient avec une enveloppe construite ailleurs. Troisième point, le chiffrement à clé publique AES-256 est une fonctionnalité PDF 2.0, donc HotPDF monte la version du document à 2.0 automatiquement. Avec StrictVersionLock positionné sur une version inférieure, EnablePubKeyEncryption rend la main sans rien activer, et l'échec n'apparaît qu'à la ligne suivante sous la forme « call EnablePubKeyEncryption first ». Changer de chiffrement pendant une mise à jour incrémentale lève EInvalidOpException immédiatement

Ajouter des destinataires ECDH : P-256, P-384, P-521, X25519 et X448

Pour des certificats à courbes elliptiques, AddPubKeyAgreementRecipientWithSecret écrit un destinataire d'accord de clé CMS (KeyAgreeRecipientInfo, la structure KARI du RFC 5753, avec le profil X25519 et X448 du RFC 8418) et calcule le secret partagé ECDH dans le processus. Vous choisissez la courbe avec une valeur THPDFPubKeyAgreementScheme : pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 ou pkasX448. Le schéma doit correspondre à la clé du certificat, sinon l'appel lève « Certificate key does not match the requested agreement scheme ». Sous le capot, chaque enveloppe reçoit un UKM aléatoire tout neuf de 32 octets, une clé de chiffrement de clé dérivée avec le KDF stdDH (SHA-256 pour P-256 et X25519, SHA-384 pour P-384, SHA-512 pour P-521 et X448), et un key wrap AES-256 au sens du RFC 3394. Le secret partagé lui-même sort de code de courbes en Pascal pur, sans fournisseur crypto de plateforme ; l'article sur l'arithmétique des courbes NIST en Pascal pur explique comment cette couche a été construite et vérifiée. Pour les courbes de Montgomery, toute la paire de clé éphémère peut être générée localement :

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Scalaire éphémère neuf par enveloppe ; le clamping se produit dans la ladder
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint : n'a de sens que pour les courbes NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Les courbes NIST demandent plus à l'appelant. HotPDF ne livre des helpers de clé publique que pour X25519 et X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), donc pour P-256, P-384 et P-521 vous générez la paire de clé éphémère avec votre propre outillage et passez un scalaire big-endian de la taille de corps exacte (32, 48 ou 66 octets) plus le point non compressé 0x04||X||Y correspondant en tant que OriginatorPublicKey. HotPDF valide le point du destinataire contre l'équation de la courbe, mais il ne peut pas vérifier que votre clé publique d'initiateur appartient réellement à votre scalaire. Des moitiés qui ne correspondent pas produisent quand même une enveloppe parfaitement bien formée qu'aucun destinataire ne peut ouvrir, et c'est pourquoi un chargement aller-retour a sa place dans votre suite de tests, pas qu'une vérification de taille de fichier

Schéma de l'accord ECDH HotPDF : AddPubKeyAgreementRecipientWithSecret dérive le secret partagé avec du code de courbes en Pascal pur, mélange un UKM neuf de 32 octets dans le KDF stdDH avec SHA-256 pour P-256 et X25519, SHA-384 pour P-384, SHA-512 pour P-521 et X448, puis emballe la clé de contenu avec le key wrap AES-256 du RFC 3394 pour bâtir l'enveloppe KeyAgreeRecipientInfo
La valeur de schéma, de pkasECDHP256 à pkasX448, doit correspondre à la clé du certificat, et des moitiés scalaire et point public qui ne correspondent pas produisent quand même une enveloppe bien formée qu'aucun destinataire ne peut ouvrir

Pourquoi l'ordre de /Recipients compte-t-il ?

L'ordre de /Recipients compte parce que la clé de fichier est un condensat sur la graine et chaque enveloppe dans l'ordre du tableau, donc écrivain et lecteur doivent hacher les mêmes octets dans la même séquence. HotPDF garde les enveloppes dans l'ordre où vous les ajoutez et les écrit inchangées, ce qui veut dire que vous pouvez ajouter les destinataires dans n'importe quel ordre, mais rien en aval ne peut réordonner, réencoder ou « nettoyer » ce tableau. La plupart des vrais bugs de ce secteur étaient une variation sur ce thème, deux parties hachant des octets légèrement différents :

  • Stocker des tableaux dynamiques dans une TList via Add ne garde qu'un pointeur brut pendant que le compte de références reste avec la variable locale. Le prochain SetLength libère le tampon et peut le réutiliser, donc chaque case finissait par aliaser la dernière enveloppe et les fichiers multi-destinataires dérivaient la mauvaise clé. Le correctif est de stocker une copie possédée avec List.Add(Pointer(System.Copy(Bytes)))
  • Le déballage d'enveloppe analyse le DER sur place, et la passe de récupération de clé hachait à l'origine ces mêmes tableaux vivants. Le lecteur photographie maintenant des copies immaculées de chaque enveloppe avant que tout déballage ne les touche, et le condensat court sur les photographies
  • Du DER binaire passé par une TStringList Unicode voit ses octets à $80 ou au-dessus réencodés par la page de code, donc HotPDF stocke les enveloppes en texte hexadécimal en interne
  • Les chaînes chiffrées et binaires doivent être écrites comme chaînes hexadécimales. Une chaîne littérale subit la normalisation des fins de ligne, où CR, LF et CRLF deviennent tous un unique LF (ISO 32000-1 §7.3.4.2), et cela réécrit le chiffré en silence. HotPDF émet chaque entrée /Recipients comme chaîne hexadécimale et l'exempte du chiffrement des chaînes, puisque tout lecteur a besoin des enveloppes avant de détenir une clé
  • Le premier octet d'un BIT STRING DER compte les bits inutilisés et doit valoir zéro pour des clés alignées sur l'octet. Le laisser non initialisé après SetLength écrivait ce qui traînait sur la pile, et un déballeur strict rejetait la clé d'initiateur, si bien qu'un fichier pouvait parfois refuser de s'ouvrir avec la clé même pour laquelle il avait été écrit
  • Quand la même clé ne peut toujours pas déchiffrer, comparez couche par couche : la clé de fichier, puis le préfixe du chiffré (l'IV), puis la clé d'objet, puis le clair. Le bug vit juste après la première couche qui diverge

Comment ouvrir un PDF chiffré par certificats avec une clé privée ?

Pour ouvrir un PDF chiffré par certificats, enregistrez la matière de clé privée avant d'appeler LoadFromFile, parce que HotPDF récupère la clé de fichier pendant la passe structurelle. Affectez une clé RSA ou EC analysée avec HPDFParsePFX à PubSecKeyMaterial, ajoutez d'autres clés RSA avec AddPubSecKeyMaterial, et enregistrez des scalaires ECDH bruts avec AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), en utilisant les constantes HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 ou HPDFOIDECP521. Les courbes NIST exigent le point public non compressé propre au destinataire ; les courbes de Montgomery l'ignorent

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Optionnel : choisir l'enveloppe directement au lieu de toutes les essayer
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = essayer chaque enveloppe dans l'ordre
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Sans callback, HotPDF essaie chaque enveloppe contre chaque clé enregistrée : la clé primaire d'abord, puis chaque clé RSA supplémentaire, puis la matière EC. PubSecRecipientQuery reçoit le nombre d'enveloppes et renvoie un index à base zéro ou -1, et un index hors tableau lève une exception au lieu d'être borné. Notez que AddPubSecKeyMaterial n'accepte que de la matière RSA (il insiste sur un module et un exposant privé), donc les clés EC vont dans PubSecKeyMaterial ou AddPubSecAgreementKeyMaterial. Quand aucune clé ne déballe aucune enveloppe, l'étape de récupération rend la main sans clé de fichier au lieu de lever, donc vérifiez que le contenu attendu a réellement déchiffré plutôt que de vous fier au retour de l'appel de chargement

Schéma du chargement de clé privée HotPDF : PubSecKeyMaterial porte la clé primaire RSA ou EC issue de HPDFParsePFX, AddPubSecKeyMaterial n'ajoute que des clés RSA, AddPubSecAgreementKeyMaterial enregistre des scalaires ECDH bruts sous les OIDs de courbes de HPDFOIDX25519 à HPDFOIDP521, et à LoadFromFile le fournisseur essaie la clé primaire, puis chaque clé RSA supplémentaire, puis la matière EC contre chaque enveloppe
Quand aucune clé ne déballe aucune enveloppe, l'étape de récupération rend la main sans clé de fichier au lieu de lever, donc vérifiez que le contenu a réellement déchiffré ou épinglez l'enveloppe via PubSecRecipientQuery

Ce que HotPDF ne garantit pas

HotPDF garantit que son propre écrivain et son propre lecteur s'accordent octet pour octet, et il construit des enveloppes qui suivent les structures CMS citées plus haut. Il ne garantit pas que chaque lecteur PDF ouvre chaque combinaison. Le support du transport de clé RSA-OAEP et des destinataires X25519 ou X448 varie selon les lecteurs et les versions, et nous n'avons pas publié de résultats de compatibilité pour ces combinaisons. Si un document doit s'ouvrir dans un lecteur précis, chiffrez un fichier de test pour un certificat de test du même type de clé et ouvrez-le là avant de vous engager sur un schéma. Les permissions portées par l'enveloppe restent une politique que les logiciels conformes honorent, exactement comme sous chiffrement par mot de passe. La qualité de la graine est aussi votre affaire : AESGenerateRandomBytes est là pour ce travail, et HotPDF efface sa copie de la graine une fois la clé de fichier dérivée. Si vous avez en plus besoin qu'une chaîne, un flux ou une pièce jointe utilise un autre crypt filter, le guide des politiques de crypt filter pour StmF, StrF et EFF montre quels noms de filtres le gestionnaire à clé publique accepte

Le chiffrement par certificats, les enveloppes de destinataires RSA-OAEP et ECDH, et le chargement de clé privée sont tous livrés dans le composant PDF HotPDF pour Delphi, aux côtés du chiffrement par mot de passe, des signatures numériques et du reste de la boîte à outils ISO 32000 pour Delphi et C++Builder