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

Επικύρωση υπογραφών PDF με OpenSSL στο PDFium VCL

Το PDFium VCL μεταχειρίζεται την επικύρωση CMS ως ανταλλάξιμο backend πίσω από το interface IPdfCmsVerifier, ώστε ο validator PAdES να τρέχει σε Windows μέσω CryptoAPI, σε macOS μέσω Keychain, και οπουδήποτε υπάρχει OpenSSL μέσω ConfigureSslCmsVerifier. Το interface είναι μικρό. Τρεις συμπεριφορές OpenSSL από κάτω του παράγουν σίγουρες, λάθος απαντήσεις αν το υλοποιήσετε ναϊβικά

Το κίνητρο είναι αρκετά καθαρό μόλις μια εφαρμογή Delphi εγκαταλείπει τα Windows. Η επικύρωση υπογραφών είναι ένα από τα λίγα πεδία όπου το crypto stack της πλατφόρμας δεν είναι λεπτομέρεια υλοποίησης: αποφασίζει ποια certificates είναι εμπιστευτικά, ποιοι αλγόριθμοι υπάρχουν, και τι σημαίνει ανάκληση. Κωδικοποιήστε ένα άκαμπτα και ο κώδικας δεν κάνει port. Αφαιρέστε το άσχημα και κάθε πλατφόρμα αναφέρει διαφορετικού σχήματος απάντηση που ο caller δεν μπορεί να συγκρίνει

Τι πρέπει στην πραγματικότητα να κουβαλά η αφαίρεση

Δύο σχήματα επικύρωσης και τρεις ανεξάρτητες ετυμηγορίες. Μια υπογραφή PDF είναι detached: το υπογεγραμμένο περιεχόμενο είναι τα δύο εύρη bytes στις δύο πλευρές της τρύπας /Contents, οπότε το VerifyDetached παίρνει δύο τμήματα και όχι ένα buffer. Ένα token timestamp είναι attached, κουβαλώντας το περιεχόμενό του, οπότε το VerifyAttached παίρνει μόνο το DER

Το αποτέλεσμα χωρίζεται σε τρεις καταστάσεις επειδή απαντούν τρεις διαφορετικές ερωτήσεις και μπορούν να διαφωνούν. Το SignatureStatus λέει αν τα bytes υπογράφηκαν από το κλειδί στο certificate του υπογράφοντα. Το TrustStatus λέει αν εκείνο το certificate αλυσίδωνεται σε κάτι που εμπιστεύεστε. Το RevocationStatus λέει αν το certificate ήταν ακόμα έγκυρο τη σχετική στιγμή. Ένα έγγραφο με μαθηματικά τέλεια υπογραφή από certificate που δεν έχετε ακούσει ποτέ είναι έγκυρο, αναξιόπιστο και άγνωστο, και η συμπίεση εκείνου σε ένα boolean είναι ο τρόπος που οι validators καταλήγουν να λένε ψέματα στους χρήστες

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, μπορεί να είναι κενό
  ConfigureSslCrls(LoadFreshCrls);                // DER, μπορεί να είναι κενό
  ConfigureSslCmsVerifier;                        // εγκαθιστά το backend

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

Το SslAbiLayout μοιάζει με περιέργεια και δεν είναι. Κάθε κωδικός σφάλματος OpenSSL και κάθε σημαία store διασχίζει το όριο ως C unsigned long, που είναι τέσσερα bytes σε Windows και οκτώ σε Linux και macOS. Δηλώστε το ως σταθερό τύπο 32-bit και ο κώδικας δουλεύει σε Windows, μετά διαβάζει σιωπηλά μισή τιμή σε LP64. Η αναφορά των υποτιθέμενων πλατών ως συμβολοσειρά πάνω στην οποία κάνετε assert σε test γυρνά ολόκληρη κατηγορία απόκλισης ABI πλατφόρμας σε έλεγχο μίας γραμμής. Όποιος έχει παλέψει το ίδιο πρόβλημα με CK_ULONG σε δέσμευση PKCS#11 θα το αναγνωρίσει αμέσως· εκείνη η ιστορία είναι στο struct packing και πλάτος CK_ULONG σε PKCS#11

Γιατί το δεύτερο πέρασμα επικύρωσης βλέπει κενό περιεχόμενο;

Επειδή το CMS_verify διαβάζει το detached BIO περιεχομένου μέχρι τέλος αρχείου, και ένα BIO που έχει διαβαστεί δεν ξανατυλίγεται για εσάς. Η επικύρωση σε δύο περάσματα είναι λογικός σχεδιασμός, πρώτα η κρυπτογραφική υπογραφή μόνη με καταστυλμένη την αξιολόγηση αλυσίδας, μετά η πλήρης αξιολόγηση, και αποτυγχάνει με ασυνήθιστα παραπλανητικό τρόπο αν και τα δύο περάσματα μοιράζονται ένα BIO

Το δεύτερο πέρασμα παίρνει μηδέν bytes περιεχομένου. Σε detached λειτουργία αυτό δεν είναι σφάλμα, γιατί κενό buffer περιεχομένου είναι νόμιμη είσοδος. Το digest απλώς δεν ταιριάζει, και η αποτυχία έρχεται στην επιφάνεια ως αποτυχία χτισίματος αλυσίδας και όχι ως αποτυχία περιεχομένου, που σας στέλνει να εξετάζετε certificates και trust stores ενώ το πραγματικό πρόβλημα είναι θέση ροής. Ξαναχτίστε το memory BIO με BIO_new_mem_buf για κάθε πέρασμα. Κοστίζει μία δέσμευση μνήμης και αφαιρεί την πιθανότητα εντελώς

Τι καταστέλλει και τι όχι η σημαία no-verify

Η CMS_NO_SIGNER_CERT_VERIFY καταστέλλει την αξιολόγηση αλυσίδας, όχι την αναζήτηση certificate υπογράφοντα. Εσωτερικά η OpenSSL επιλύει και επισυνάπτει τα certificates του υπογράφοντα προτού συμβουλευτεί τη σημαία, οπότε μετά από πρώτο πέρασμα με εκείνη τη σημαία ο υπογράφων είναι ήδη διαθέσιμος και οι αναγνωριστικοί αλγορίθμοι του διαβάζονται κατευθείαν. Δεν χρειάζεται να τρέξετε δεύτερη πλήρη επικύρωση απλώς για να πάρετε το certificate του υπογράφοντα, που είναι όσο σας παρασύρει να υποθέσει το όνομα της σημαίας

Ένας κανόνας ιδιοκτησίας πηγαίνει με αυτό. Η αναφορά υπογράφοντα ανήκει στη δομή CMS και δεν πρέπει να απελευθερώνεται ανεξάρτητα. Ισχύει για όσο ισχύει η δομή, και η απελευθέρωσή της παράγει φθορά της οποίας το σύμπτωμα εμφανίζεται κάπου εντελώς αλλού, συνήθως κατά τον καθαρισμό ενός άσχετου αντικειμένου

Γιατί το άναμμα ελέγχου CRL απορρίπτει κάθε υπογραφή;

Επειδή η OpenSSL ελέγχει CRLs μόνο απέναντι σε όσα κρατά ήδη το store και δεν αντλεί τίποτα μόνη της. Δεν ακολουθεί CRL distribution points και δεν μιλά OCSP. Ορίστε X509_V_FLAG_CRL_CHECK σε store χωρίς CRLs μέσα και κάθε αλυσίδα αποτυγχάνει με αδυναμία απόκτησης CRL certificate. Το αποτέλεσμα μοιάζει με έλεγχο ανακλήσεων που δουλεύει και βρίσκει προβλήματα. Είναι έλεγχος ανακλήσεων που δεν τρέχει καθόλου

Το backend επομένως ορίζει τη σημαία μόνο όταν το ConfigureSslCrls έχει πραγματικά παραδώσει τουλάχιστον μία CRL. Χωρίς μία, το RevocationStatus γυρνά ως pcvsUnsupported, που είναι ειλικρινής δήλωση ότι η ερώτηση δεν απαντήθηκε. Για τον ίδιο λόγο το OnlineRetrieval δεν έχει επίδραση σε αυτό το backend και δεν εκπέμπεται checkpoint pcvstOnlineRetrieval: δεν υπάρχει μονοπάτι άντλησης από όπου να αναφέρει πρόοδο

Διάγραμμα verifier CMS OpenSSL του PDFium VCL με τρεις παγίδες: ένα κοινό BIO περιεχομένου διαβασμένο μέχρι τέλος αρχείου αφήνει το δεύτερο πέρασμα επικύρωσης με μηδέν bytes, το CMS_NO_SIGNER_CERT_VERIFY καταστέλλει αξιολόγηση αλυσίδας αλλά όχι αναζήτηση υπογράφοντα, και ο έλεγχος CRL σε κενό store απορρίπτει κάθε αλυσίδα χωρίς η ανάκληση να τρέξει ποτέ
Κάθε παγίδα δίνει σίγουρη λάθος ετυμηγορία: μια θέση ροής παραλλάσσεται ως αποτυχία εμπιστοσύνης, η σημαία no-verify καταστέλλει λιγότερα απ όσα υπονοεί το όνομά της, και η μη τρέξασα ποτέ ανάκληση φαίνεται σαν ανάκληση που βρήκε προβλήματα

Αυτή είναι σχεδιαστική θέση που αξίζει υπεράσπιση γενικά. Ένας validator που δεν μπορεί να ελέγξει ανακλήσεις πρέπει να το λέει. Η αναφορά ανελέγκτου certificate ως μη ανακλημένου είναι ο πιο συχνός τρόπος που τα εργαλεία επικύρωσης υπογραφών παραπλανούν τους χρήστες τους, και είναι ακριβώς η κατηγορία σύγχυσης που εξετάζεται στο γιατί οι validators απορρίπτουν υπογραφές PAdES

// Τα checkpoints αφήνουν ένα UI να δείχνει ποιο στάδιο τρέχει, και σας
// λένε ποια στάδια εκτελεί πραγματικά ένα backend
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Διαβάστε τις τρεις ετυμηγορίες χωριστά· επιτρέπεται να διαφωνούν
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Δέσμευση σε βιβλιοθήκη που δεν μπορείτε να καρφιτσώσετε

Η OpenSSL μετονόμασε τους accessors του stack της ανάμεσα στο 1.0 και στο 1.1, οπότε η ίδια λογική συνάρτηση έχει δύο πιθανά ονόματα εξαγωγής ανάλογα με το build που τυχαίνει να έχει ο host. Η δέσμευση επιλύει πρώτα το νεότερο όνομα και πέφτει στο παλαιότερο, και καταγράφει λείπον symbol μόνο όταν δεν επιλύεται κανένα. Είναι το σωστό σχήμα για κάθε δυναμική δέσμευση σε βιβλιοθήκη που δεν παραδίδετε: προτιμήστε τρέχοντα ονόματα, ανεχτείτε ιστορικά, και αναφέρετε μόνο γνήσια απουσία

Το SslMissingSymbols είναι όσο γυρνά μια αποτυχημένη φόρτωση σε διαγνώσιμο συμβάν. Ένα μη κενό αποτέλεσμα σε host που προφανώς έχει libcrypto εγκατεστημένη σημαίνει ότι η εγκατεστημένη έκδοση είναι παλαιότερη από το API που στοχεύει αυτό το build, που είναι εντελώς διαφορετική συζήτηση υποστήριξης από μια βιβλιοθήκη που λείπει. Το ConfigureSslLibraryPath καλύπτει την άλλη συχνή περίπτωση, host με αρκετά builds OpenSSL όπου εκείνο στο προεπιλεγμένο search path δεν είναι όσο θέλετε

Επιλογή backend ανά πλατφόρμα

Η πρακτική διευθέτηση είναι να διαλέγετε στην εκκίνηση και να καταγράφετε ποιος απάντησε. Σε Windows, το backend πλατφόρμας ενσωματώνεται με τα certificate stores που διαχειρίζεται ήδη μια επιχείρηση, που είναι κανονικά όσο θέλετε. Σε macOS το backend Keychain ταιριάζει στην ίδια λογική και περιγράφεται στην επικύρωση υπογραφών με SecTrust στο macOS. Η OpenSSL είναι η φορητή επιλογή, και είναι επίσης η σωστή όταν θέλετε πολιτική επικύρωσης που είναι ταυτόσημη ανάμεσα σε πλατφόρμες και όχι μία που ακολουθεί το trust store κάθε πλατφόρμας

Διάγραμμα PDFium VCL της αφαίρεσης IPdfCmsVerifier που κουβαλά VerifyDetached πάνω στα δύο εύρη bytes γύρω από την τρύπα Contents και VerifyAttached για tokens timestamp, τις τρεις ανεξάρτητες ετυμηγορίες SignatureStatus, TrustStatus και RevocationStatus, και backends ανά πλατφόρμα διαλεγμένους στην εκκίνηση μέσω CryptoAPI, SecTrust ή ConfigureSslCmsVerifier
Το interface κουβαλά δύο σχήματα επικύρωσης και τρεις ετυμηγορίες επειδή απαντούν διαφορετικές ερωτήσεις και μπορεί να διαφωνούν, και το εγκατεστημένο backend καταγράφεται δίπλα σε κάθε ετυμηγορία ώστε τα αποθηκευμένα αποτελέσματα να αναπαράγονται

Όποιον κι αν εγκαταστήσετε, καταγράφετε το PadesCmsVerificationBackendName δίπλα σε κάθε ετυμηγορία που καταγράφετε. Ένα αποθηκευμένο αποτέλεσμα επικύρωσης χωρίς το backend που το παρήγαγε δεν αναπαράγεται αργότερα, επειδή οι τρεις τιμές κατάστασης σημαίνουν λεπτά διαφορετικά πράγματα ανάλογα με ποιο stack απάντησε. Το στρώμα εξέτασης υπογραφών πάνω από όλα αυτά, συμπεριλαμβανομένου του πώς αναφέρονται τα επίπεδα PAdES, καλύπτεται στο εξέταση ψηφιακών υπογραφών PDF και επιπέδων PAdES

Όλα παραδίδονται ως πηγαίος κώδικας με το PDFium Delphi component, που μετράει εδώ περισσότερο από συνήθως: για validator υπογραφών, η δυνατότητα να διαβάσεις ακριβώς ποιες σημαίες ορίζει ένα backend και ποιους ελέγχους προσπερνά δεν είναι πολυτέλεια, είναι ο μόνος τρόπος να ξέρεις τι ισχυρίζεται στην πραγματικότητα ένα πράσινο τικ στην εφαρμογή σου