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

Ανάλυση αλλαγών PDF μετά την υπογραφή με PDFium στο Delphi

Για να μάθετε τι άλλαξε σε ένα PDF αφού υπογράφηκε, το PDFium Component για Delphi και Lazarus προσφέρει το TPdf.AnalyzeSignatureRevisions, έναν αναλυτή αλλαγών αναθεωρήσεων μετά την υπογραφή που ξαναχτίζει κάθε σταδιακή αναθεώρηση από τα αρχικά bytes του αρχείου, βαθμολογεί κάθε μεταγενέστερη αλλαγή αντικειμένου απέναντι στους κανόνες DocMDP και FieldMDP εκείνης της υπογραφής, και αναφέρει ορισμούς shadow αντικειμένων ως ξεχωριστό ρίσκο. Η περίπτωση που στοχεύει είναι οικεία σε όποιον χειρίζεται συμβόλαια: μια πιστοποιημένη φόρμα φεύγει, επιστρέφει με άλλα δύο σταδιακά saves, και κάθε υπογραφή εξακολουθεί να επαληθεύεται. Αυτό είναι το αναμενόμενο, αφού μια υπογραφή καλύπτει μόνο τα bytes της δικής της αναθεώρησης. Η πραγματική ερώτηση είναι αν εκείνα τα μεταγενέστερα saves επιτρέπονταν, και ένα πράσινο τικ στην υπογραφή δεν απαντά

Γιατί το API υπογραφών του PDFium δεν δείχνει τι άλλαξε μετά την υπογραφή;

Το API υπογραφών του PDFium δεν μπορεί να δείξει μετα-υπογραφικές αλλαγές επειδή διαβάζει μόνο το λεξικό υπογραφής: /Contents, /ByteRange, /SubFilter και την τιμή άδειας DocMDP. Το PDFium δεν έχει γράφο σταδιακών αναθεωρήσεων, δεν κάνει parse τις παραμέτρους transform του FieldMDP, και δεν προσφέρει diff επιπέδου αντικειμένου ανάμεσα σε αναθεωρήσεις, οπότε ο αναλυτής στο FPdfPades.pas δουλεύει απευθείας πάνω σε ακατέργαστα bytes. Αυτό έχει μια πρακτική συνέπεια που αξίζει να σχεδιάσετε γύρω της. Το TPdf.AnalyzeSignatureRevisions διαβάζει τα bytes που κρατήθηκαν όταν φορτώθηκε το έγγραφο, ποτέ ένα αντίγραφο παραγόμενο από SaveAs, επειδή ένα ξαναγραμμένο αρχείο έχει χάσει την ίδια τη δομή αναθεωρήσεων που αναλύεται. Αν το έγγραφο ήρθε από μια προοδευτική πηγή που δεν τελείωσε το download, η αναφορά επιστρέφει SourceStatus = pvssIncomplete και Status = prasIndeterminate αντί να αναλύσει ένα κομμένο αρχείο

Ξαναχτίσιμο ορίων αναθεωρήσεων από startxref, xref streams και /Prev

Ο αναλυτής ξαναχτίζει τα όρια αναθεωρήσεων ακολουθώντας κάθε startxref προς τα πίσω μέσα από κλασικούς πίνακες xref, streams cross-reference, εγγραφές hybrid-reference /XRefStm και την αλυσίδα /Prev, όπως ορίζεται για σταδιακές ενημερώσεις στο ISO 32000-1 §7.5.6 και §7.5.8. Το καλυμμένο μήκος κάθε υπογραφής είναι το τέλος του δεύτερου διαστήματος ByteRange της, και ο αναλυτής χαρτογραφεί εκείνο το μήκος στην αναθεώρηση της οποίας η ενότητα xref πέφτει μέσα. Όταν καμία αναθεώρηση δεν ταιριάζει, η υπογραφή παίρνει prrCoveredRevisionNotFound και κατάσταση Indeterminate. Η κατάσταση κάθε αντικειμένου ξαναπαίζεται έπειτα ως την καλυμμένη αναθεώρηση, και κάθε μεταγενέστερη εγγραφή xref συγκρίνεται με εκείνη την κατάσταση. Αυτό μετράει περισσότερο απ' όσο ακούγεται: κάποιοι writers ξαναδιατυπώνουν τον πλήρη πίνακα xref σε κάθε σταδιακό save, και μια εγγραφή που εξακολουθεί να δείχνει στο ίδιο αμετάβλητο αντικείμενο προσπερνιέται αντί να αναφερθεί ως τροποποίηση. Χωρίς εκείνη τη σύγκριση, ένα απόλυτα νόμιμο γέμισμα φόρμας θα πνιγόταν σε εκατοντάδες ψεύτικες αλλαγές

Πώς ξαναχτίζει το AnalyzeSignatureRevisions σταδιακές αναθεωρήσεις από ακατέργαστα bytes PDF στο Delphi: το ByteRange της υπογραφής τελειώνει μέσα στην καλυμμένη αναθεώρηση, η αλυσίδα Prev του xref περπατά προς τα πίσω μέσα από κάθε μεταγενέστερο save, η κατάσταση αντικειμένων ξαναπαίζεται ως την καλυμμένη αναθεώρηση, και οι αμετάβλητες ξαναδιατυπωμένες εγγραφές προσπερνιούνται αντί να αναφέρονται ως αλλαγές
Μια υπογραφή καλύπτει μόνο τα bytes της δικής της αναθεώρησης, οπότε ο αναλυτής χαρτογραφεί το δεύτερο διάστημα ByteRange σε αναθεώρηση και βαθμολογεί κάθε μεταγενέστερη εγγραφή xref απέναντι στην ξαναπαιγμένη κατάσταση αντικειμένων

Οι shadow ορισμοί είναι η περίπτωση που αξίζει την περισσότερη προσοχή. Ένα body αντικειμένου που εμφανίζεται μέσα στο εύρος bytes μιας μεταγενέστερης αναθεώρησης αλλά δεν αναφέρεται από το xref εκείνης της αναθεώρησης είναι αόρατο σε έναν συνηθισμένο viewer, και όμως είναι ακριβώς το είδος της προετοιμασίας που στηρίζουν οι επιθέσεις shadow: κρυμμένο περιεχόμενο φυτεύεται πριν ή μετά την υπογραφή και ενεργοποιείται αργότερα αναποδογυρίζοντας μια αναφορά. Το AnalyzePadesSignatureRevisionsBytes καταγράφει ένα τέτοιο αντικείμενο ως μη αυθεντική αλλαγή με IsAuthoritative = False, το βαθμολογεί prdSuspicious ανεξάρτητα από το επίπεδο άδειας, και προσθέτει prrUnreferencedObjectDefinition στο σύνολο ρίσκων. Δύο συγγενικά ρίσκα καλύπτουν άλλα δομικά κόλπα: το prrDuplicateObjectDefinition πυροδοτείται όταν μία ενότητα xref απαριθμεί το ίδιο αντικείμενο πάνω από μία φορά, και το prrSignatureObjectRedefined όταν μια μεταγενέστερη αναθεώρηση ξαναορίζει υπάρχον αντικείμενο υπογραφής

Ένας ορισμός shadow αντικειμένου μέσα στο εύρος bytes μεταγενέστερης αναθεώρησης PDF: το body του αντικειμένου υπάρχει αλλά καμία εγγραφή xref δεν το αναφέρει, ώστε οι viewers να μην το δείχνουν ποτέ, και το AnalyzeSignatureRevisions στο PDFium Component το καταγράφει ως μη αυθεντικό, το βαθμολογεί prdSuspicious και σημειώνει prrUnreferencedObjectDefinition δίπλα στα ρίσκα duplicate και redefined signature
Κρυμμένο περιεχόμενο φυτεύεται πριν ή μετά την υπογραφή και ενεργοποιείται αργότερα αναποδογυρίζοντας μια αναφορά, και γι' αυτό ένα μη αναφερόμενο body βαθμολογείται ύποπτο ανεξάρτητα από το επίπεδο άδειας DocMDP
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

Πώς επιβάλλονται το DocMDP και το FieldMDP για κάθε υπογραφή;

Το DocMDP και το FieldMDP επιβάλλονται ξεχωριστά για κάθε υπογραφή, στη δική της καλυμμένη αναθεώρηση, ώστε μια υπογραφή πιστοποίησης και μια μεταγενέστερη υπογραφή έγκρισης στο ίδιο αρχείο να μπορούν να καταλήξουν σε διαφορετικές ετυμηγορίες για την ίδια επεξεργασία. Κάθε μεταγενέστερο αντικείμενο ταξινομείται πρώτα σε ένα TPadesRevisionChangeKind από τις εγγραφές /Type, /Subtype και /FT του και από τον ρόλο που παίζει στους γράφους σελίδας, φόρμας, σχολιασμού και DSS. Οτιδήποτε κουβαλάει /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia ή /EmbeddedFile γίνεται prckActiveContent. Η απόφαση ακολουθεί έπειτα το ISO 32000-1 §12.8.2.2: με P=1 απαγορεύεται οτιδήποτε εκτός από δεδομένα cross-reference και υλικό επαλήθευσης· το P=2 επιτρέπει γέμισμα φόρμας και περαιτέρω υπογραφές αλλά απορρίπτει αλλαγές σχολιασμών· το P=3 επιτρέπει και σχολιασμούς. Περιεχόμενο σελίδας, δομή εγγράφου, metadata, ενεργό περιεχόμενο και διαγραμμένα αντικείμενα απαγορεύονται σε οποιοδήποτε επίπεδο DocMDP, και βαθμολογούνται prdSuspicious όταν η υπογραφή δεν κουβαλάει καθόλου DocMDP, αφού μια υπογραφή έγκρισης δεν απαγορεύει τίποτα τυπικά αλλά ο αναγνώστης δεν βλέπει πια τι ήταν υπογεγραμμένο

Το FieldMDP, από το ISO 32000-1 §12.8.2.4, στενεύει περαιτέρω την απόφαση για τα πεδία φόρμας. Το pfmaAll κλειδώνει κάθε πεδίο, το pfmaInclude κλειδώνει μόνο τα απαριθμούμενα πεδία, και το pfmaExclude κλειδώνει όλα εκτός από τα απαριθμούμενα. Για να εφαρμόσει Include ή Exclude, ο αναλυτής επιλύει κάθε αλλαγμένο πεδίο στο πλήρως προσδιορισμένο όνομά του μέσα από την αλυσίδα /Parent και το συγκρίνει με τη λίστα κλειδώματος με ακριβή αντιστοίχιση, οπότε απαριθμήστε ονόματα τελικών πεδίων αντί να περιμένετε ένα όνομα γονέα να καλύπτει τα παιδιά του. Όταν ένα όνομα δεν επιλύεται ή το transform χρησιμοποιεί ενέργεια που ο parser δεν αναγνωρίζει, η αλλαγή γίνεται prdIndeterminate και σημειώνεται prrFieldMdpUnresolved. Οι αποφάσεις ανά αλλαγή αναδιπλώνονται έπειτα χειρότερο-πρώτο, με το Suspicious πάνω από το Disallowed, το Disallowed πάνω από το Indeterminate, και το Indeterminate πάνω από το Allowed, ώστε ένα shadow αντικείμενο να ζυγίζει περισσότερο από όποιον αριθμό νόμιμων ενημερώσεων πεδίων

Ο σωλήνας βαθμολόγησης που εφαρμόζει το AnalyzeSignatureRevisions σε κάθε μετα-υπογραφική αλλαγή στο Delphi: ένα TPadesRevisionChangeKind από εγγραφές Type και Subtype, μια απόφαση DocMDP στην καλυμμένη αναθεώρηση από P=1 έως P=3, ένας έλεγχος κλειδώματος FieldMDP σε πλήρως προσδιορισμένα ονόματα πεδίων, και ένα roll-up χειρότερο-πρώτο από prdSuspicious ως το prdAllowed
Ένα shadow αντικείμενο ζυγίζει περισσότερο από όποιον αριθμό νόμιμων ενημερώσεων πεδίων επειδή το Suspicious κατάγεται πάνω από το Disallowed, το Indeterminate και το Allowed, ενώ κάποια ρίσκα καταγράφονται δίπλα στην κατάσταση χωρίς να την υποβιβάζουν

Γιατί κάποιες αλλαγές επιστρέφουν Indeterminate αντί για ασφαλείς;

Οι αλλαγές επιστρέφουν Indeterminate όποτε ο αναλυτής δεν μπορεί να αποδείξει ότι μια αλλαγή επιτρέπεται, επειδή σε έναν έλεγχο υπογραφής ένα άγνωστο δεν επιτρέπεται να αναφερθεί ποτέ ως επιτρεπτό. Μια συχνή περίπτωση χειρίζεται ακριβώς αντί αυτού: η μακροχρόνια επαλήθευση προσθέτει ένα /DSS και ξαναγράφει τον κατάλογο, που αλλιώς θα μετρούσε ως δομική αλλαγή κάτω από P=1. Ο αναλυτής αφαιρεί /DSS και /Extensions από τα παλιά και νέα λεξικά καταλόγου και συγκρίνει τα υπόλοιπα· όταν τίποτα άλλο δεν διαφέρει, το ξαναγράψιμο αντιμετωπίζεται ως ενημέρωση υλικού επαλήθευσης και επιτρέπεται, ώστε η διεύρυνση B-LT και B-LTA να μην σπάει μια υπογραφή πιστοποίησης. Άλλα κενά μένουν ανοιχτά επίτηδες. Οι εγγραφές Type-2 σε ένα stream cross-reference δείχνουν μέσα σε συμπιεσμένα object streams, και ο αναλυτής δεν αναπτύσσει object streams μέσα σε αυτό το όριο ασφαλείας, ώστε εκείνες οι αλλαγές να αναδύονται ως prckCompressedObject με prrCompressedObjectUnresolved, απαγορευμένες κάτω από P=1 και Indeterminate αλλιώς. Σκληρά πλαφόν 1024 αναθεωρήσεων, 1.000.000 αριθμών αντικειμένων και 2.000.000 αναφερόμενων αλλαγών παράγουν prrResourceLimitExceeded, και μια σπασμένη αλυσίδα xref παράγει prrMalformedRevisionChain· και τα δύο τελειώνουν ως Indeterminate, ποτέ ως πέρασμα

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Κάποια ρίσκα καταγράφονται χωρίς να αλλάζει το Status, οπότε τεστάρετέ τα πρώτα
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

Η σειρά σε εκείνη την πύλη είναι σκόπιμη. Το prrDuplicateObjectDefinition προστίθεται στο σύνολο ρίσκων χωρίς να υποβιβάζει μόνο του το Status, και ένα transform FieldMDP που δεν μπορεί να γίνει parse επηρεάζει την κατάσταση μόνο όταν κάποιο πεδίο φόρμας αλλάζει πραγματικά, ώστε μια πύλη που κοιτάζει μόνο το Status μπορεί να χάσει αποδείξεις που η αναφορά ήδη περιέχει. Έχετε επίσης νου τι δεν ισχυρίζεται η αναφορά. Το TPadesRevisionAnalysisReport δεν λέει τίποτα για το αν η υπογραφή CMS είναι κρυπτογραφικά έγκυρη ή αν το πιστοποιητικό του υπογράφοντα αλυσιδώνεται σε μια ρίζα που εμπιστεύεστε. Η ανάλυση αναθεωρήσεων απαντά στην ερώτηση τι συνέβη μετά την υπογραφή, και ανήκει δίπλα στη δομική και στην επικύρωση εμπιστοσύνης, όχι στη θέση τους

Γραφή seed values και κλειδωμάτων MDP τη στιγμή της υπογραφής

Οι ίδιοι κανόνες μπορούν να συγγραφούν την ώρα της υπογραφής μέσω του TPadesSignatureFieldOptions, που είναι το μέλος FieldOptions και του TPadesSignOptions και του TPadesRemoteSignOptions. Το PDFium μπορεί να δημιουργήσει widget αλλά δεν μπορεί να γράψει /SV, /Lock, transform FieldMDP ή DocMDP, ή το λεξικό /Perms του καταλόγου, οπότε ο δικός του σταδιακός writer PAdES του component παράγει αυτά τα αντικείμενα μέσα στην ίδια ενημέρωση xref με την υπογραφή. Το FieldName ορίζει το όνομα της ριζικής ομάδας πεδίων, το RequiredSeedValues γίνεται τα bits /Ff του λεξικού seed-value που περιγράφεται στο ISO 32000-1 §12.7.4.5, τα Reasons, LegalAttestations και AcceptableCertificates περιορίζουν τι μπορεί να διαλέξει ένας μεταγενέστερος υπογράφων, το LockAction με LockFields γράφει ένα έμμεσο /SigFieldLock, και το CertificationPermission από 1 έως 3 μετατρέπει την υπογραφή σε υπογραφή πιστοποίησης. Τα transform DocMDP και FieldMDP μπαίνουν και τα δύο σε ένα array /Reference πάνω στην τιμή της υπογραφής, το καθένα με /Data που δείχνει τον κατάλογο

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // μόνο γέμισμα φόρμας και υπογραφή
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // κλείδωσε μόνο αυτά τα πεδία
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

Μερικές λεπτομέρειες είναι εύκολο να πάνε στραβά αν το φτιάξετε με το χέρι. Το /Perms /DocMDP του καταλόγου πρέπει να αναφέρει το λεξικό τιμής της υπογραφής, όχι τον σχολιασμό widget, και ο writer κρατάει την τιμή της υπογραφής ως δικό της έμμεσο αντικείμενο για αυτόν τον λόγο. Ένα υπάρχον λεξικό /Perms μπορεί ήδη να κρατάει δικαιώματα χρήσης /UR3, οπότε ο writer το αντιγράφει και εισάγει /DocMDP αντί να το αντικαταστήσει, ακολουθώντας το λεξικό αδειών στο ISO 32000-1 §12.8.4. Ένα έγγραφο που ήδη κουβαλάει /DocMDP αρνείται δεύτερη υπογραφή πιστοποίησης με EPadesCrypto, και το ίδιο κάνουν οι ασυνεπείς επιλογές: κλείδωμα Include ή Exclude χωρίς ονόματα πεδίων, κλείδωμα All με λίστα πεδίων, νομική βεβαίωση πάνω σε υπογραφή που δεν είναι πιστοποίησης, ή τελεία στο όνομα της ριζικής ομάδας πεδίων. Η απομακρυσμένη υπογραφή προσθέτει έναν ακόμη κανόνα, επειδή το πιστοποιητικό υπογραφής είναι άγνωστο όταν τρέχει το PreparePadesRemoteSignature: το να ορίσετε εκεί CertificateRequired απαιτεί ρητή λίστα AcceptableCertificates, ενώ η τοπική υπογραφή μπορεί να πέσει πίσω στο επιλυμένο πιστοποιητικό του υπογράφοντα

Η ανάλυση αναθεωρήσεων συμπληρώνει την εργαλειοθήκη υπογραφών αντί να αντικαθιστά όποιο μέρος της. Ξεκινήστε από το inspection υπογραφών PDF και επιπέδων PAdES με το PDFium Component για να διαβάσετε το λεξικό και το βασικό επίπεδο, δείτε το γιατί απορρίπτουν οι validators υπογραφές PAdES για τις δομικές αποτυχίες που έρχονται πριν από κάθε ερώτηση αναθεωρήσεων, και διπλώστε την ετυμηγορία σε έναν ευρύτερο έλεγχο ρίσκων ασφαλείας PDF δίπλα στους ελέγχους JavaScript και embedded files. Το TPdf.AnalyzeSignatureRevisions, το TPadesSignatureFieldOptions και ο σταδιακός writer PAdES που δείχτηκε εδώ έρχονται με το PDFium Component για Delphi, C++Builder και Lazarus