Τεχνικό Άρθρο

Signature wrapping PDF: κενά ByteRange και δεύτερη υπογραφή

Το HotPDF, το Delphi PDF component, απορρίπτει πλέον το signature wrapping: από το v2.759.0 τόσο το VerifyLoadedSignatureEx όσο και ο batch validator απαιτούν το κενό ανάμεσα στα δύο τμήματα /ByteRange να είναι ακριβώς το hex string /Contents, delimiters συμπεριλαμβανομένων, και το v2.761.0 προσθέτει το AddLoadedSignedSignatureField ώστε μια δεύτερη υπογραφή να μπορεί να προσατηθεί σε ήδη υπογεγραμμένο PDF ως καθαρή incremental αναθεώρηση. Οι δύο αλλαγές πάνε μαζί, επειδή μια σωστή δεύτερη υπογραφή είναι ακριβώς η διάταξη που περιμένει ο αυστηρότερος verifier

Η κατάσταση που αποκάλυψε το πρόβλημα είναι συνηθισμένη. Ένα συμβόλαιο υπογράφεται από τον προμηθευτή, μετά δρομολογείται σε έναν εγκρίτη που πρέπει να υπογράψει από πάνω χωρίς να ταράξει την πρώτη υπογραφή. Η δεύτερη αναθεώρηση προσάπτεται μετά την πρώτη, το δικό της /ByteRange απλώνεται σε όλο το μεγαλωμένο αρχείο, και οι δύο υπογραφές θα έπρεπε να επαληθεύονται. Το να φτάσετε εκεί στο χέρι σήμαινε να γράψετε μόνοι σας ένα incremental τμήμα, και το test fixture που έκανε ακριβώς αυτό αποδείχθηκε μια διδακτική δομή signature wrapping που ο παλιός verifier δέχεται με χαρά. Αν δεν έχετε κοιτάξει ποτέ το API επαλήθευσης, ο οδηγός για επαλήθευση ψηφιακών υπογραφών PDF με το HotPDF καλύπτει τα βασικά πάνω στα οποία χτίζει αυτό το άρθρο

Τι ακριβώς ανήκει στο κενό του ByteRange;

Το κενό πρέπει να περιέχει την πλήρη τιμή /Contents και τίποτα άλλο: το ISO 32000-1 §12.8.3.3 λέει ότι το δεκαεξαδικό string, με τους delimiters < και > του, χωράει ακριβώς στον χώρο ανάμεσα στα δύο byte ranges, και το ISO 32000-2 §12.8.1 κουβαλά τον ίδιο κανόνα μπροστά. Ο Table 252 και τα έγγραφα PAdES λένε μόνο ότι ο digest εξαιρεί την τιμή Contents, που διαβάζεται εύκολα ως εξαίρεση μόνο των hex ψηφίων. Παλαιότερες εκδόσεις του HotPDF το διάβαζαν έτσι: το PreparePDFForSigning και η streaming προετοιμασία CMS έκαναν hash και τις αγκύλες, με ένα σχόλιο πηγαίου κώδικα να επιμένει ότι οι αγκύλες έπρεπε να καλύπτονται. Validators που συγκρίνουν το κενό με την τιμή υπογραφής σημειώνουν εκείνη τη διάταξη ως άκυρο byte range, οπότε το v2.759.0 βγάζει και τους δύο delimiters έξω από τα υπογεγραμένα ranges. Ένας γρήγορος ανεξάρτητος έλεγχος σε οποιοδήποτε υπογεγραμμένο αρχείο είναι να κοιτάξετε δύο bytes: το byte στο offset ByteRange[1] πρέπει να είναι < και το byte στο offset ByteRange[2] - 1 πρέπει να είναι >

Ανατομία ενός σωστά γεμισμένου ByteRange υπογραφής PDF στο HotPDF: το πρώτο range καλύπτει το αρχείο από το byte μηδέν, το κενό κρατά το πλήρες hex string /Contents συμπεριλαμβανομένων των delimiters less-than και greater-than, το δεύτερο range καλύπτει το trailer ως το τέλος, και δύο έλεγχοι ενός byte στο ByteRange[1] και στο ByteRange[2] - 1 επιβεβαιώνουν τη διάταξη σε οποιοδήποτε υπογεγραμμένο αρχείο
Από το v2.759.0 οι delimiters κάθονται έξω από τα υπογεγραμένα ranges, οπότε ο digest καλύπτει μόνο τα ψηφία και το κενό μπορεί να επικυρωθεί byte προς byte

Γιατί ο έλεγχος για μη κενό κενό χάνει το signature wrapping;

Ένας έλεγχος μη κενού κενού αποδεικνύει μόνο ότι κάτι αφέθηκε έξω από τον digest, όχι τι αφέθηκε έξω, και εκείνη είναι όλη η επιφάνεια επίθεσης. Το placeholder /Contents δεσμεύεται με χιλιάδες ψηφία μηδενός, ενώ ένα πραγματικό container CMS σπάνια το γεμίζει. Ένας attacker μπορεί να κλείσει το hex string νωρίς μέσα σε εκείνο το μηδενικό padding με ένα >, να γράψει νέα objects ή πλαστή αναθεώρηση στο υπόλοιπο της δεσμευμένης θέσης, και να αφήσει τα byte ranges ανέγγιχτα. Η υπογραφή CMS εξακολουθεί να επαληθεύεται επειδή κάθε υπογεγραμμένο byte είναι αμετάβλητο, τα ranges εξακολουθούν να ξεκινούν στο 0 και να τελειώνουν στο μέγεθος του αρχείου, και ο παλιός verifier του HotPDF ανέφερε svValid με CoversWholeDocument σε True. Ένας PDF reader, στο μεταξύ, κάνει parse ό,τι κάθεται σε εκείνη τη μη υπογεγραμμένη τρύπα

Το HotPDF τώρα μεταχειρίζεται το κενό ως δεδομένα προς επικύρωση byte προς byte. Ο verifier διαβάζει το κενό, ξυρίζει τους delimiters, δέχεται μόνο hex ψηφία συν PDF whitespace (tab, line feed, form feed, carriage return, κενό), αποκωδικοποιεί τα ψηφία και απαιτεί το αποτέλεσμα να ισούται ακριβώς με το /Contents του dictionary υπογραφής. Οτιδήποτε άλλο υποβαθμίζει το αποτέλεσμα σε svInvalidByteRange. Ο έλεγχος τρέχει και στη διαδρομή μονής υπογραφής και στο ValidateLoadedSignatureBatch, που κρατούσε τη δική του λογική κάλυψης και χρειαζόταν το ίδιο fix. Αρχεία που παρήγαγε το HotPDF πριν το v2.759.0, με κενό που κρατούσε μόνο ψηφία με τις αγκύλες καθισμένες μόλις μέσα στα ranges, εξακολουθούν να επαληθεύονται, οπότε αρχειοθετημένα έγγραφα δεν γίνονται ξαφνικά κόκκινα

Πώς το signature wrapping εκμεταλλεύεται ένα χαλαρά ελεγμένο ByteRange PDF στο Delphi: ο attacker κλείνει το hex string νωρίς μέσα σε χιλιάδες δεσμευμένα ψηφία μηδενός, γράφει πλαστή αναθεώρηση στο μη υπογεγραμμένο κενό χωρίς να αγγίξει κανένα καλυμμένο byte, και ο παλιός έλεγχος του HotPDF ανέφερε svValid με CoversWholeDocument true μέχρι το v2.759.0 να αρχίσει να επικυρώνει το κενό byte προς byte
Ένα μη κενό κενό αποδεικνύει μόνο ότι κάτι αφέθηκε έξω από τον digest, όχι τι — η τρύπα με το padding είναι όλη η επιφάνεια επίθεσης
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;

Πώς προσθέτετε δεύτερη υπογραφή σε ήδη υπογεγραμμένο PDF;

Ανοίξτε το υπογεγραμμένο αρχείο με BeginIncrementalUpdate, καλέστε το AddLoadedSignedSignatureField, αποθηκεύστε με SaveIncrementalUpdate, μετά υπογράψτε το προετοιμασμένο αρχείο με τη class function THotPDF.SignPDFWithPFX. Πριν το v2.761.0 η τεκμηριωμένη συνταγή της κλήσης THPDFPage.AddSignedSignatureField μετά από BeginIncrementalUpdate δεν μπορούσε να δουλέψει, επειδή το CurrentPage είναι nil σε incremental mode και τίποτα δεν μπορούσε να προσατήσει placeholder /V σε πεδίο πάνω σε φορτωμένο έγγραφο. Η νέα μέθοδος δημιουργεί το widget στη φορτωμένη σελίδα και κρεμά το ίδιο dictionary placeholder που χρησιμοποιεί η διαδρομή νέου εγγράφου κάτω από το /V, οπότε και οι δύο διαδρομές υπογραφής μοιράζονται μία serialization. Για την ίδια την πρώτη υπογραφή, το άρθρο για δημιουργία ψηφιακών υπογραφών PAdES στο Delphi περπατά τον αγωγό PFX

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Σελίδα 0, ορθογώνιο widget σε points, 8192 bytes δεσμευμένα για το 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 είναι επίτηδες πιο ήσυχο από τα αδέλφια του. Οι άλλοι δημιουργοί πεδίων AddLoaded* θέτουν /NeedAppearances true στην AcroForm, που λέει σε έναν viewer να αναγεννήσει appearances πεδίων· σε υπογεγραμμένο έγγραφο εκείνη η αναγέννηση μπορεί να ξαναγράψει υπογεγραμμένο content, οπότε η νέα μέθοδος αφαιρεί ξανά τη σημαία εκτός αν η πηγή την κουβαλούσε ήδη. Το /SigFlags κρατά την αρχική του τιμή OR 3 (SignaturesExist συν AppendOnly, ISO 32000-1 Table 219). Δεν χρειάζεστε επίσης να καλέσετε MarkDirty στη σελίδα: η προσθήκη σε /Annots και /Fields διαδίδει τη σημαία dirty στο owning έμμεσο object, και ένα ρητό σήμα σελίδας θα έσερνε μόνο ένα αμετάβλητο dictionary σελίδας στη νέα αναθεώρηση, που η ανάλυση αναθεωρήσεων μετά αναφέρει ως τροποποίηση σελίδας. Τέλος, το placeholder γράφει /ByteRange πριν το /Contents, επειδή ο patcher εντοπίζει πρώτα τον sentinel /ByteRange και ψάχνει προς τα εμπρός για το αντίστοιχο hex string

Η ροή εργασίας HotPDF Delphi για υπογραφή από πάνω σε ήδη υπογεγραμμένο PDF: το BeginIncrementalUpdate ανοίγει το αρχείο, το AddLoadedSignedSignatureField δημιουργεί το widget και δεσμεύει το placeholder /Contents, το SaveIncrementalUpdate προσάπτει δεύτερη αναθεώρηση, και το SignPDFWithPFX τη γεμίζει, αφήνοντας την πρώτη υπογραφή έγκυρη με UnsignedTrailingBytes ενώ το νέο ByteRange απλώνεται σε όλο το μεγαλωμένο αρχείο
Ένα placeholder ανά αναθεώρηση, προετοιμασμένο και διασκευασμένο από την ίδια serialization και στις δύο διαδρομές υπογραφής — η καθαρή διάταξη που περιμένει ο αυστηρότερος verifier

Τι αλλάζει όταν εξωτερικός signer ή HSM παράγει το CMS;

Τίποτα δεν αλλάζει στη ροή εργασίας, αλλά τα offsets πλέον σημαίνουν ό,τι λέει το specification. Το PreparePDFForSigning επιστρέφει δύο 0-based ranges με κενό όλο το string /Contents, και το ContentsHexStart είναι ο δείκτης 1-based του πρώτου hex ψηφίου στο AnsiString. Ένα συντομότερο CMS γεμίζεται με 0 στο τέλος, πριν από την κλείουσα >. Επειδή το PreparePDFForSigning διασκευάζει τον πρώτο ανασκευασμένο sentinel που βρίσκει, προετοιμάστε ακριβώς ένα placeholder ανά αναθεώρηση, και προτιμήστε το InsertSignatureHexAt με τα επιστρεφόμενα offsets από το search-based InsertSignatureHex όταν προγενέστερες υπογραφές υπάρχουν ήδη στο αρχείο

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

  // Το κενό είναι όλο το hex string: το < κλείνει το range 1, το > προηγείται του range 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

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

Πού είναι τα όρια των νέων ελέγχων;

Ο έλεγχος του κενού κλείνει μία συγκεκριμένη τρύπα και δεν πρέπει να τον πουλήσετε υπερβολικά. Το svValid εξακολουθεί να σημαίνει ακεραιότητα bytes συν κλειδί που ταιριάζει το ενσωματωμένο πιστοποιητικό· η εμπιστοσύνη σε εκείνο το πιστοποιητικό είναι ξεχωριστή απόφαση. Το κενό επικυρώνεται μόνο όταν ο verifier έχει bytes πηγής, που το VerifyLoadedSignatureEx διαβάζει από το φορτωμένο αρχείο και τα overloads TStream τα παίρνουν από εσάς. Για την πρώτη υπογραφή σε αρχείο με υπογραφή από πάνω, το CoversWholeDocument είναι σωστά False, και το αν η προσαπτημένη αναθεώρηση πρόσθεσε μόνο υπογραφή ή άλλαξε και σελίδες είναι ερώτηση για την ανάλυση DocMDP, FieldMDP και αναθεωρήσεων στο HotPDF. Σημειώστε επίσης ότι ο έλεγχος attached PDF MAC συγκρίνει offsets με τις θέσεις < και >, οπότε δέχεται και την παλιά και τη νέα διάταξη· οποιοδήποτε δικό σας εργαλείο που έχει hard-code τα offsets πριν το v2.759.0 θα αποτύχει πρώτο όταν συναντήσει φρεσκοϋπογεγραμμένο αρχείο

Αν η εφαρμογή σας σε Delphi ή C++Builder υπογράφει, υπογράφει από πάνω ή ελέγχει PDFs, ο πιο ασφαλής δρόμος είναι να αφήσετε μία βιβλιοθήκη να παράγει και να επαληθεύει την ίδια διάταξη. Το HotPDF, το εγγενές Delphi PDF component κουβαλά την αυστηρότερη επικύρωση κενού, incremental δεύτερες υπογραφές και τα hooks εξωτερικού signer που δείχθηκαν παραπάνω σε ένα ενιαίο component