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

Επαλήθευση αλυσίδας PDF MAC σε Delphi (ISO 32004)

Το HotPDF επαληθεύει ένα ISO/TS 32004 PDF MAC ανά αναθεώρηση και όχι ανά αρχείο. Το THotPDF.ValidatePDFMACChain περπατά κάθε incremental update από την άγκυρα της αλυσίδας και μετά, και επαληθεύει κάθε MAC έναντι ενός read-only prefix stream που τελειώνει στο δικό του startxref και %%EOF εκείνης της αναθεώρησης. Ένα έγκυρο MAC στην νεότερη αναθεώρηση δεν αποδεικνύει τίποτα για τις αναθεωρήσεις από κάτω

Εδώ είναι το σενάριο που κινητοποιεί όλα αυτά. Στέλνετε ένα AES-256 κρυπτογραφημένο PDF με PDF MAC πάνω του. Κάποιος ανοίγει το αρχείο σε hex editor, αναποδογυρίζει ένα byte μέσα στην πρώτη MAC-προστατευμένη αναθεώρηση, και μετά προσαρτά μια εντελώς καινούργια αναθεώρηση που κουβαλά απολύτως έγκυρο MAC δικό του. Κάθε viewer ανοίγει το αρχείο χωρίς παράπονο, και ένας αφελής checker που κάνει hash το τρέχον byte range έναντι του MAC στο ενεργό trailer αναφέρει επιτυχία — επειδή εκείνο το MAC όντως είναι σωστό για τα bytes που καλύπτει. Η ζημιά κάθεται δύο αναθεωρήσεις κάτω, σε περιοχή που κανείς δεν ξαναέλεγξε

Γιατί ένα έγκυρο MAC κορυφαίου επιπέδου δεν αποδεικνύει ότι το αρχείο είναι άθικτο;

Επειδή ένα PDF MAC καλύπτει ένα prefix, όχι ένα έγγραφο. Το incremental update είναι πρώτης τάξης μέρος της μορφής: κάθε αποθήκευση προσαρτά νέο body, νέο cross-reference section και νέο trailer, ενώ τα παλαιότερα bytes μένουν ακριβώς εκεί που ήταν. Το ISO/TS 32004 ιππεύει πάνω σε αυτό το μοντέλο, ώστε κάθε αναθεώρηση κουβαλά το δικό της λεξικό /AuthCode που αυθεντικοποιεί το αρχείο όπως ήταν εκείνη τη στιγμή, και η επαλήθευση μόνο του νεότερου αφήνει κάθε προηγούμενη αναθεώρηση ανεξέταστη. Το HotPDF λοιπόν εκθέτει τις δύο ερωτήσεις ως δύο κλήσεις, και η διαφορά μεταξύ τους είναι όλο το νόημα αυτού του άρθρου. Το ValidatePDFMAC απαντά «είναι αυθεντική η τρέχουσα αναθεώρηση», γεμίζοντας μια εγγραφή THPDFPDFMACValidationInfo· το ValidatePDFMACChain απαντά «είναι αυθεντική κάθε MAC-προστατευμένη αναθεώρηση σε αυτό το αρχείο», γεμίζοντας το THPDFPDFMACChainValidationInfo με πίνακα ανά αναθεώρηση συν αναγνώσιμο από μηχανή λόγο αποτυχίας. Στο παραπάνω αρχείο που παραποιήθηκε και μετά ξανακλείστηκε με MAC, η πρώτη κλήση επιστρέφει True και η δεύτερη False στο δείκτη αναθεώρησης 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // Η αποτυχία είναι ένα από pmcfRevisionBoundary, pmcfNoPDFMAC,
      //  pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
      //  pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

Κάθε MAC επαληθεύεται στο δικό του prefix stream, ποτέ στο τελικό μήκος αρχείου

Το ακριβότερο bug σε αυτή την περιοχή είναι η χρήση του τελικού μεγέθους αρχείου ως άνω ορίου όταν κάνετε re-hash μια παλαιότερη αναθεώρηση, που διπλώνει τα trailing bytes σε κάθε digest εκτός του νεότερου και αναφέρει παραποίηση σε υγιές αρχείο. Το HotPDF αντίθετα ανακατασκευάζει, για κάθε αναθεώρηση, ένα οριοθετημένο read-only stream που τελειώνει στην τιμή startxref εκείνης της αναθεώρησης ακολουθούμενη από το %%EOF της, και κάνει hash μόνο αυτό. Ο εντοπισμός του ορίου είναι πιο ιδιότροπος απ' όσο φαίνεται: το κυριολεκτικό %%EOF μπορεί να εμφανιστεί μέσα σε content stream ή string, ώστε υποψήφιο γίνεται δεκτό μόνο όταν το αμέσως προηγούμενο startxref αναλύεται σε αριθμό ίσο με το cross-reference offset της ενότητας που επαληθεύεται, με τίποτα άλλο εκτός whitespace μεταξύ τους. Η αναθεώρηση τότε απορροφά ακριβώς μία ακολουθία end-of-line μετά τον δείκτη — ένα μονό CR, ένα μονό LF, ή ένα ζεύγος CRLF — και τίποτα παραπάνω. Αυτός ο τελευταίος κανόνας δαγκώνει στην πράξη, επειδή ένας writer που εκπέμπει επιπλέον κενή γραμμή μεταξύ δύο αναθεωρήσεων έχει παράγει bytes που ανήκουν στην επόμενη αναθεώρηση, και η κατάποση όλου του trailing whitespace στην προηγούμενη αλλάζει σιωπηλά και τα δύο digests. Η απαρίθμηση ενοτήτων ακολουθεί την ίδια πειθαρχία: το HotPDF περπατά τις cross-reference ενότητες από τις παλαιότερες στις νεότερες ακριβώς μία φορά, επαναλαμβάνοντας εγγραφές free, direct και object-stream ώστε νεότερες ενότητες να υπεργράφουν προγενέστερη κατάσταση, που είναι το αντίθετο από τη σημασιολογία first-seen-wins που εφαρμόζει ένας active-xref parser

Το HotPDF επαληθεύει κάθε ISO 32004 PDF MAC έναντι prefix stream που τελειώνει στο δικό του startxref και δείκτη τέλους αρχείου εκείνης της αναθεώρησης, ώστε ένα byte αναποδογυρισμένο μέσα στην αναθεώρηση 1 αποτυγχάνει την αλυσίδα παρότι το νεότερο MAC εξακολουθεί να επαληθεύεται καθαρά
Το MAC κάθε αναθεώρησης ξαναχτενίζεται με hash πάνω στο δικό του οριοθετημένο prefix, ώστε επεξεργασία της αναθεώρησης 1 και προσάρτηση αναθεώρησης με φρέσκο MAC ικανοποιούν ακόμα το ValidatePDFMAC ενώ το ValidatePDFMACChain προσγειώνεται στην αναθεώρηση 1

Πού αγκυρώνει η αλυσίδα, και τι τη σπάει;

Η πρώτη αναθεώρηση που κουβαλά έγκυρο /AuthCode είναι η άγκυρα, και το FirstMACRevisionIndex αναφέρει πού αρχίζει η προστασία· οτιδήποτε πριν από αυτό είναι απροστάτευτο εκ κατασκευής, κάτι φυσιολογικό. Οτιδήποτε μετά από αυτό πρέπει να είναι MAC-προστατευμένο, ώστε η προσάρτηση ενός απλού incremental update σε MAC-προστατευμένο αρχείο αποτυγχάνει με pmcfRequiredRevisionMissing και τον δείκτη της παραβατικής αναθεώρησης — η ανοχή ενός κενού θα άφηνε επιτιθέμενο να αφαιρέσει προστασία απλώς αποθηκεύοντας άλλη μία φορά. Τρεις επιπλέον αναλλοίωτες ισχύουν σε όλη την αλυσίδα, καθεμία με δικό της κωδικό αποτυχίας

  • pmcfKDFSaltChanged — το /KDFSalt πρέπει να μείνει σταθερό από την άγκυρα και μετά, αφού ένας εναλλασσόμενος salt θα άφηνε πλαστογράφο να ξανα-παράγει κλειδιά υπό παραμέτρους δικής του επιλογής
  • pmcfDigestDowngrade — η ισχύς digest συγκρίνεται έναντι του τελευταίου επαληθευμένου MAC και όχι της αμέσως προηγούμενης αναθεώρησης, ώστε αλυσίδα που ξεκινά κάτω από το Modern profile σε SHA-384 δεν μπορεί να συνεχίσει σιωπηλά με SHA-256
  • pmcfPermissionDowngrade — μια αναθεώρηση δεν επιτρέπεται να απαλείψει απαίτηση PDF MAC που μια προγενέστερη αναθεώρηση είχε αυθεντικοποιήσει

Η συνέπεια που αξίζει να εμπεδώσετε είναι ότι τα ιστορικά MAC επαληθεύονται ανεξάρτητα ακόμα κι όταν παύουν να είναι το ενεργό trailer. Γι' αυτό η επίθεση επεξεργασίας-παλιάς-αναθεώρησης-και-προσάρτησης-φρέσκου-MAC από την εισαγωγή δεν επιβιώνει: το νεότερο MAC περνά από μόνο του, το ValidatePDFMAC είναι ικανοποιημένο, και η αλυσίδα προσγειώνεται ακόμα στην αναθεώρηση 1 με pmcfRevisionInvalid

Σειρά υπογραφής: πρώτα τα κλειδιά του trailer, τελευταίο το signatureDigest

Όταν το MAC επισυνάπτεται σε υπογραφή CMS αντί να στέκεται μόνο του, η σειρά γραφής παύει να είναι ερώτηση στυλ. Το HotPDF απαιτεί /AuthCode, /KDFSalt, την επέκταση developer ISO 32004 και το /SigObjRef να γραφτούν στην ίδια αναθεώρηση πριν υπολογιστεί η /ByteRange της υπογραφής· προσθέστε οποιοδήποτε από αυτά μετά και εκείνα τα bytes προσγειώνονται έξω από το range που καλύπτει η υπογραφή, παράγοντας αρχείο του οποίου η υπογραφή επαληθεύεται ενώ η δέσμευση MAC είναι χωρίς υπογραφή. Τα δύο digests τρέχουν προς την άλλη κατεύθυνση, που φαίνεται κυκλικό με πρώτη ματιά και δεν είναι. Το signatureDigest του PDF MAC δεσμεύει τα ακατέργαστα content octets του OCTET STRING SignerInfo.signature του CMS — όχι όλο το CMS DER, και όχι τα signed attributes — ώστε κατασκευάζεται αφού υπάρχει η ακατέργαστη τιμή υπογραφής και ενίεται ως unsigned attribute id-attr-pdfMacData. Επειδή το /Contents εξαιρείται από το ByteRange της υπογραφής και τα unsigned attributes δεν τρέφουν ποτέ τον υπολογισμό υπογραφής, η ακολουθία παραγωγή-υπογραφής, χτίσιμο-MAC, τύλιγμα-CMS κλείνει καθαρά χωρίς κρυπτογραφικό βρόχο. Δύο corollaries ακολουθούν: ο φρουρός /ByteRange και το placeholder /Contents πρέπει να μένουν plaintext και έξω από object streams ακόμα και σε κρυπτογραφημένο αρχείο, αλλιώς ο fixed-width patcher δεν μπορεί να τους βρει· και όταν το MAC digest είναι επίσης SHA-256 το signing digest ξαναχρησιμοποιείται ευθέως, αλλιώς και τα δύο digest contexts ενημερώνονται σε μία μοναδική διέλευση του output stream

Η σειρά γραφής HotPDF για PDF MAC επισυναπτόμενο σε υπογραφή CMS: τα κλειδιά MAC μπαίνουν στην αναθεώρηση πριν μετρηθεί η ByteRange, και το signature digest χτίζεται μετά από τα ακατέργαστα octets υπογραφής SignerInfo
Το να γράφετε AuthCode, KDFSalt, SigObjRef και την επέκταση developer πριν μετρηθεί η ByteRange είναι αυτό που κρατά τη δέσμευση MAC μέσα στο range που καλύπτει η υπογραφή
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // digest εγγράφου SHA-384
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature, και τα δύο digests
        // αναφέρονται ξεχωριστά
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

Η επαλήθευση ξαναχαράσσει την ίδια διαδρομή από το άλλο άκρο: διαβάστε το direct /AuthCode από το τρέχον ενεργό classic cross-reference trailer, ακολουθήστε το έμμεσο /SigObjRef με επίγνωση γενεάς, βεβαιωθείτε ότι δεσμεύει το /V του ενός πεδίου υπογραφής, και αναφέρετε αποτυχία document-digest ξεχωριστά από αποτυχία signature-digest. Αυτές είναι διαφορετικές διαγνώσεις, και η κατάρρευσή τους σε ένα boolean πετάει τη μόνη πληροφορία που λέει αν το page content ή η τιμή υπογραφής αγγγίχτηκε. Αν κάνετε ήδη δουλειά CMS, αυτό κάθεται δίπλα στο άρθρο υπογραφής PAdES και στον οδηγό επαλήθευσης υπογραφών σε φορτωμένα έγγραφα

Μην εμπιστευτείτε ποτέ το /P: αποκρυπτογραφήστε πρώτα το 16-byte /Perms

Το ISO/TS 32004 σηματοδοτεί «αυτό το έγγραφο απαιτεί PDF MAC» μέσω του bit δικαιώματος 13, και ο προφανής τρόπος ανάγνωσής του είναι ο λάθος, επειδή ο ακέραιος /P στο λεξικό κρυπτογράφησης είναι plaintext και μη αυθεντικοποιημένος — ο καθένας μπορεί να αναποδογυρίσει εκείνο το bit σε text editor και να υποβαθμίσει την απαίτηση. Το ISO 32000-2 §7.6 προμηθεύει την απάντηση στην εγγραφή /Perms, και το HotPDF τη χρησιμοποιεί: αποκρυπτογραφήστε το string /Perms των 16 byte με το κλειδί κρυπτογράφησης αρχείου υπό AES-256 CBC, μηδενικό IV, χωρίς padding, και μετά ελέγξτε κάθε πεδίο του plaintext πριν πιστέψετε οτιδήποτε. Τα bytes 1 έως 4 κρατούν την τιμή δικαιώματος σε little-endian σειρά και πρέπει να ισούνται ακριβώς με τον ακέραιο /P· τα bytes 5 έως 8 είναι 0xFF· το byte 9 είναι η σημαία κρυπτογράφησης metadata T ή F· τα bytes 10 έως 12 είναι ο κυριολεκτικός δείκτης adb. Μόνο όταν όλα αυτά ισχύουν γίνεται το PermissionsAuthenticated True και διαβάζεται το bit 13 — και προσέξτε την πολικότητά του, αφού η απαίτηση MAC διεκδικείται όταν το bit 0x1000 είναι σβηστό. Μια αναντιστοιχία μεταξύ /P και των αποκρυπτογραφημένων δικαιωμάτων δεν είναι προειδοποίηση για καταγραφή και προσπέραση· είναι πλαστό σύνολο δικαιωμάτων, και η σωστή απόκριση είναι να αποτύχετε κλειστά

Το HotPDF αυθεντικοποιεί τα δικαιώματα PDF αποκρυπτογραφώντας το string Perms δεκαέξι byte με το κλειδί κρυπτογράφησης αρχείου και ελέγχοντας την τιμή δικαιώματος little-endian, τα filler bytes FF, τη σημαία metadata και τον δείκτη adb πριν διαβάσει το bit 13
Ο plaintext ακέραιος /P είναι μη αυθεντικοποιημένος, ώστε η απαίτηση PDF MAC διαβάζεται μόνο αφού έχει ελεγχθεί κάθε πεδίο του αποκρυπτογραφημένου /Perms

Η ευελιξία αλγορίθμου σταματά στο digest

Το ISO/TS 32004 σάς αφήνει να διαλέξετε το digest εγγράφου, και μόνο το digest εγγράφου. Το HotPDF κρατά HMAC-SHA-256 για αυθεντικοποίηση, HKDF-SHA-256 κατά RFC 5869 για παραγωγή κλειδιών και AES-256 key wrap κατά RFC 3394 σταθερά κάτω από μια μεταβλητή THPDFPDFMACDigestAlgorithm που απλώνεται από pmdaSHA256 έως pmdaSHA3_512, επειδή το φυσικό λάθος είναι να αντιμετωπίσετε ένα «SHA3-512 profile» ως άδεια να αλλάξετε και το HMAC, που παράγει αρχείο που δεν είναι πλέον PDF MAC με καμία διαλειτουργική έννοια. Μια λεπτομέρεια υλοποίησης αξίζει αντιγραφή αν γράφετε δικό σας verifier: διαβάστε το OID digest από το AuthenticatedData του CMS πριν κάνετε hash το byte range, αφού hardcoded SHA-256 και συμφιλίωση μετά μετατρέπουν την ευελιξία σε ετικέτα και αφήνουν ένα εχθρικό αρχείο να σας ωθήσει να περάσετε όλο το έγγραφο σε stream πριν ανακαλύψετε ότι ο αλγόριθμος δεν υποστηριζόταν ποτέ. Το CMSAlgorithmProtection, ο αλγόριθμος digest του AuthenticatedData, το integrity-info messageDigest και το digest byte-range πρέπει όλα να ονομάζουν έναν αλγόριθμο, και οποιαδήποτε διαφωνία αποτυγχάνει κλειστά

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, δέχεται και τα έξι
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, απορρίπτει 256-bit
  Options := THPDFPDFMACOptions.HighAssurance;  // μόνο SHA3-512, AES-GCM

  // Ένα custom profile είναι νόμιμο, αλλά ο αλγόριθμος με τον οποίο
  //  παράγει πρέπει να εμφανίζεται και στην allowlist επικύρωσης, ή η
  //  διαμόρφωση απορρίπτεται πριν γραφεί έστω ένα byte
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

Τι αποδεικνύει και τι δεν αποδεικνύει ένα PDF MAC

Μια επαληθευμένη αλυσίδα PDF MAC αποδεικνύει ότι κάθε προστατευμένη αναθεώρηση είναι byte-πανομοιότυπη με ό,τι έγραψε κάποιος που κρατούσε το κλειδί κρυπτογράφησης αρχείου, ότι καμία προστατευμένη αναθεώρηση δεν αφαιρέθηκε ή αναδιατάχθηκε, και ότι καμία απροστάτευτη αναθεώρηση δεν προσαρτήθηκε μετά την άγκυρα — ακριβώς η κατηγορία επίθεσης που η σκέτη κρυπτογράφηση AES-256 ανοιχτή αφήνει, αφού η εμπιστευτικότητα δεν λέει τίποτα για την ακεραιότητα και ένα κρυπτογραφημένο PDF με συγκολλημένη αναθεώρηση αποκρυπτογραφείται το ίδιο χαρούμενα με ένα άθικτο. Τι δεν αποδεικνύει είναι πατρότητα. Το κλειδί MAC παράγεται από το κλειδί κρυπτογράφησης αρχείου, ώστε όποιος μπορεί να ανοίξει το έγγραφο μπορεί επίσης να παράγει έγκυρο MAC πάνω σε τροποποιημένη έκδοση, κάθε νόμιμος παραλήπτης συμπεριλαμβανόμενος· είναι συμμετρικό primitive, και συμμετρικά primitives δεν αποδίδουν πατρότητα. Αν χρειάζεστε να ξέρετε ποιος άλλαξε κάτι χρειάζεστε ψηφιακή υπογραφή με πιστοποιητικό πίσω της, και το PDF MAC τότε τη συμπληρώνει προστατεύοντας τη δομή incremental που η υπογραφή μόνη δεν καλύπτει. Αντιμετωπίστε τα ως layers και αφήστε τις δύο ετυμηγορίες να αναφέρονται ανεξάρτητα αντί να καταρρέουν σε ένα εικονίδιο κατάστασης

Τα entry points PDF MAC που περιγράφονται εδώ — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC και ValidatePDFMACChain — στέλνονται με το standard HotPDF Delphi Component για Delphi και C++Builder, όπου η σελίδα προϊόντος κουβαλά την πλήρη αναφορά για την εγγραφή options, τις απαριθμήσεις κατάστασης και τον πίνακα επαλήθευσης ανά αναθεώρηση