Article technique

Signature wrapping PDF : ByteRange et seconde signature

HotPDF, le composant PDF Delphi, rejette maintenant le signature wrapping : depuis la v2.759.0, VerifyLoadedSignatureEx comme le validateur par lot exigent que le trou entre les deux segments /ByteRange soit exactement la chaîne hexadécimale /Contents, délimiteurs compris, et la v2.761.0 ajoute AddLoadedSignedSignatureField pour qu'une seconde signature puisse s'ajouter à un PDF déjà signé comme une révision incrémentale propre. Les deux changements vont ensemble, parce qu'une seconde signature correcte est précisément la disposition qu'attend le vérificateur plus strict

La situation qui a exposé le problème est banale. Un contrat est signé par le fournisseur, puis acheminé vers un approbateur qui doit contre-signer sans déranger la première signature. La seconde révision s'ajoute après la première, son propre /ByteRange couvre tout le fichier grandi, et les deux signatures devraient se vérifier. Y arriver à la main voulait dire écrire soi-même une section incrémentale, et la fixture de test qui faisait exactement ça s'est révélée être une structure de signature wrapping de manuel que l'ancien vérificateur acceptait volontiers. Si vous n'avez pas encore regardé l'API de vérification, le guide de vérification des signatures numériques PDF avec HotPDF couvre les bases sur lesquelles cet article bâtit

Que doit contenir exactement le trou du ByteRange ?

Le trou doit contenir la valeur /Contents complète et rien d'autre : ISO 32000-1 §12.8.3.3 dit que la chaîne hexadécimale, avec ses délimiteurs < et >, tient précisément dans l'espace entre les deux plages d'octets, et ISO 32000-2 §12.8.1 fait porter la même règle en avant. La Table 252 et les documents PAdES disent seulement que le condensat exclut la valeur Contents, ce qui se lit facilement comme excluant juste les chiffres hexadécimaux. Les versions HotPDF antérieures lisaient ainsi : PreparePDFForSigning et la préparation CMS en flux hachaient aussi les chevrons, avec un commentaire source insistant pour que les chevrons soient couverts. Les validateurs qui comparent le trou à la valeur de la signature signalent cette disposition comme une plage d'octets invalide, donc la v2.759.0 sort les deux délimiteurs des plages signées. Un contrôle indépendant rapide sur n'importe quel fichier signé consiste à regarder deux octets : l'octet au décalage ByteRange[1] doit être < et l'octet au décalage ByteRange[2] - 1 doit être >

Anatomie d'un ByteRange de signature PDF correctement rempli dans HotPDF : la première plage couvre le fichier depuis l'octet zéro, le trou porte la chaîne hexadécimale /Contents complète y compris les délimiteurs inférieur et supérieur, la seconde plage couvre le trailer jusqu'à la fin, et deux contrôles d'un octet à ByteRange[1] et ByteRange[2] - 1 confirment la disposition sur tout fichier signé
Depuis la v2.759.0, les délimiteurs logent hors des plages signées, donc le condensat ne couvre que les chiffres et le trou peut se valider octet par octet

Pourquoi un contrôle de trou non vide rate-t-il le signature wrapping ?

Un contrôle de trou non vide prouve seulement que quelque chose a été laissé dehors du condensat, pas quoi, et c'est toute la surface d'attaque. Le placeholder /Contents est réservé avec des milliers de chiffres zéro, alors qu'un vrai conteneur CMS le remplit rarement. Un attaquant peut fermer la chaîne hexadécimale en avance au milieu de ce bourrage de zéros avec un >, écrire de nouveaux objets ou une révision forgée dans le reste de l'espace réservé, et laisser les plages d'octets intactes. La signature CMS se vérifie toujours parce que chaque octet signé est inchangé, les plages commencent toujours à 0 et finissent à la taille du fichier, et l'ancien vérificateur HotPDF rapportait svValid avec CoversWholeDocument posé à True. Un lecteur PDF, pendant ce temps, parse tout ce qui loge dans ce trou non signé

HotPDF traite maintenant le trou comme des données à valider octet par octet. Le vérificateur lit le trou, rogne les délimiteurs, n'accepte que des chiffres hexadécimaux plus les espaces blancs PDF (tabulation, saut de ligne, saut de page, retour chariot, espace), décode les chiffres et exige que le résultat égale exactement le /Contents du dictionnaire de signature. Tout le reste rétrograde le résultat en svInvalidByteRange. Le contrôle tourne dans la voie à signature unique comme dans ValidateLoadedSignatureBatch, qui gardait sa propre logique de couverture et avait besoin de la même correction. Les fichiers produits par HotPDF avant la v2.759.0, dont le trou ne portait que des chiffres avec les crochets logeant juste à l'intérieur des plages, se vérifient toujours, donc les documents archivés ne virent pas au rouge du jour au lendemain

Comment le signature wrapping exploite un ByteRange PDF peu contrôlé en Delphi : l'attaquant ferme la chaîne hexadécimale en avance au milieu de milliers de chiffres zéro réservés, écrit une révision forgée dans le trou non signé sans toucher un octet couvert, et l'ancien contrôle HotPDF rapportait svValid avec CoversWholeDocument vrai jusqu'à ce que la v2.759.0 se mette à valider le trou octet par octet
Un trou non vide prouve seulement que quelque chose a été laissé dehors du condensat, pas quoi — le trou bourré de zéros est toute la surface d'attaque
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Comment ajouter une seconde signature à un PDF déjà signé ?

Ouvrez le fichier signé avec BeginIncrementalUpdate, appelez AddLoadedSignedSignatureField, sauvegardez avec SaveIncrementalUpdate, puis signez le fichier préparé avec la fonction de classe THotPDF.SignPDFWithPFX. Avant la v2.761.0, la recette documentée qui consistait à appeler THPDFPage.AddSignedSignatureField après BeginIncrementalUpdate ne pouvait pas marcher, parce que CurrentPage est nil en mode incrémental et que rien ne pouvait accrocher un placeholder /V à un champ sur un document chargé. La nouvelle méthode crée le widget sur la page chargée et suspend sous le /V le même dictionnaire de placeholder qu'emploie la voie du nouveau document, si bien que les deux voies de signature partagent une seule sérialisation. Pour la première signature elle-même, l'article sur la création de signatures numériques PAdES en Delphi parcourt le pipeline PFX

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Page 0, rectangle de widget en points, 8192 octets réservés pour le CMS
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureField est délibérément plus discret que ses frères. Les autres créateurs de champs AddLoaded* posent /NeedAppearances true sur l'AcroForm, ce qui dit à un lecteur de régénérer les apparences de champs ; sur un document signé, cette régénération peut réécrire du contenu signé, donc la nouvelle méthode retire à nouveau le drapeau sauf si la source le portait déjà. /SigFlags garde sa valeur d'origine OR 3 (SignaturesExist plus AppendOnly, ISO 32000-1 Table 219). Vous n'avez pas non plus besoin d'appeler MarkDirty sur la page : l'ajout aux /Annots et aux /Fields propage le drapeau dirty à l'objet indirect propriétaire, et un marquage explicite de la page ne ferait que traîner un dictionnaire de page inchangé dans la nouvelle révision, que l'analyse de révision rapporterait ensuite comme une modification de page. Enfin, le placeholder écrit /ByteRange avant /Contents, parce que le patcher localise d'abord le sentinel /ByteRange et cherche vers l'avant la chaîne hexadécimale appariée

Le workflow HotPDF Delphi pour contre-signer un PDF déjà signé : BeginIncrementalUpdate ouvre le fichier, AddLoadedSignedSignatureField crée le widget et réserve le placeholder /Contents, SaveIncrementalUpdate ajoute une seconde révision, et SignPDFWithPFX la remplit, laissant la première signature valide avec UnsignedTrailingBytes tandis que le nouveau ByteRange couvre tout le fichier grandi
Un placeholder par révision, préparé et patché par la même sérialisation sur les deux voies de signature — la disposition propre qu'attend le vérificateur plus strict

Que change le fait qu'un signeur externe ou un HSM produise le CMS ?

Rien ne change dans le workflow, mais les décalages signifient maintenant ce que dit la spécification. PreparePDFForSigning renvoie deux plages base 0 dont le trou est toute la chaîne /Contents, et ContentsHexStart est l'index base 1 du premier chiffre hexadécimal dans l'AnsiString. Un CMS plus court est bourré de 0 à la fin, avant le > de fermeture. Comme PreparePDFForSigning patche le premier sentinel non patché qu'il trouve, préparez exactement un placeholder par révision, et préférez InsertSignatureHexAt avec les décalages renvoyés à l'InsertSignatureHex fondée sur la recherche quand des signatures antérieures existent déjà dans le fichier

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // votre helper
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // Le trou est toute la chaîne hex : '<' termine la plage 1, '>' précède la plage 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // votre signeur CMS, DER hex
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // votre helper
end;

Où sont les limites des nouveaux contrôles ?

Le contrôle du trou ferme un trou bien précis et ne doit pas être survendu. svValid signifie toujours intégrité d'octets plus une clé qui colle au certificat embarqué ; la confiance dans ce certificat est une décision séparée. Le trou n'est validé que quand le vérificateur dispose des octets sources, que VerifyLoadedSignatureEx lit dans le fichier chargé et que les surcharges TStream reçoivent de vous. Pour la première signature d'un fichier contre-signé, CoversWholeDocument est correctement False, et savoir si la révision ajoutée n'a fait qu'ajouter une signature ou a aussi changé des pages est une question pour l'analyse DocMDP, FieldMDP et révision dans HotPDF. Notez aussi que le contrôle de PDF MAC attaché compare les décalages aux positions < et >, donc il accepte l'ancienne comme la nouvelle disposition ; tout outil à vous qui coderait en dur les décalages d'avant la v2.759.0 échouera d'abord en rencontrant un fichier fraîchement signé

Si votre application Delphi ou C++Builder signe, contre-signe ou audite des PDF, la voie la plus sûre est de laisser une seule bibliothèque produire et vérifier la même disposition. HotPDF, le composant PDF Delphi natif, livre la validation de trou plus stricte, les secondes signatures incrémentales et les hooks de signeur externe montrés ci-dessus dans un seul composant