Το PDFium VCL ελέγχει offline την ανάκληση υπογραφών PDF σε Windows προσθέτοντας CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY στο πέρασμα ανάκλησης του CertGetCertificateChain, επειδή η σημαία cache-only που χρησιμοποιείται για το χτίσιμο της αλυσίδας δεν καλύπτει καθόλου την ανάκτηση CRL ή OCSP. Από την v3.119.1 μια offline κλήση ValidatePadesTrust μένει εκτός δικτύου, και ένα καθαρό αποτέλεσμα απαιτεί πραγματικά στοιχεία ανάκλησης ανά πιστοποιητικό. Το υπόλοιπο αυτής της ανάρτησης αφορά το γιατί και τα δύο μισά εκείνης της πρότασης χρειάζονταν διόρθωση
Η εγκατάσταση που ξεσκεπάζει το πρόβλημα είναι συνηθισμένη. Μια υπηρεσία επικύρωσης τρέχει σε ένα κλειδωμένο host Windows, το TPadesTrustValidationOptions.NetworkPolicy είναι ptnpOffline (που είναι και η προεπιλογή), και ο χειριστής περιμένει κάθε απάντηση να βγαίνει από την τοπική cache πιστοποιητικών. Ύστερα κάποιος προσέχει εξερχόμενα αιτήματα προς σημείο διανομής CA στο log του firewall, ή μια batch εργασία που στέκεται για ολόκληρο το UrlRetrievalTimeoutMs των 15000 ms σε κάθε υπογραφή. Τίποτα στον κώδικα δεν ζήτησε το δίκτυο. Τα Windows πήγαν εκεί ούτως ή άλλως
Γιατί ένα offline χτίσιμο αλυσίδας ανακτά ακόμη CRLs σε Windows;
Επειδή το CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL περιορίζει μόνο την ανάκτηση URL που κάνει το χτίσιμο της αλυσίδας: ανακτήσεις AIA εκδότη, ενημερώσεις root και CTL. Η τεκμηρίωση της Microsoft για το CertGetCertificateChain λέει ρητά ότι η σημαία δεν εφαρμόζεται στον έλεγχο ανάκλησης. Η ανάκληση έχει τον δικό της διακόπτη, το CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), και χωρίς αυτόν οι πάροχοι ανάκλησης είναι ελεύθεροι να κατεβάσουν CRL ή να στείλουν OCSP αίτημα ακόμη κι αν η γύρω κλήση μοιάζει offline. Το PDFium VCL συνδυάζει πλέον με OR εκείνη τη σημαία στο πέρασμα ανάκλησης όποτε το OnlineRetrieval είναι False, πάνω από τις σημαίες αλυσίδας, το CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT και το CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Αυτό μετράει για περισσότερα από την καθυστέρηση: ένα OCSP αίτημα λέει στον responder ποιο πιστοποιητικό κοιτάτε, που είναι ακριβώς αυτό που οφείλει να αποφεύγει ένας air-gapped validator
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // False εξ ορισμού
Options.CheckTimeStamps := True;
// Το offline πλέον σημαίνει offline και για την ανάκληση: μόνο αποθηκευμένες
// απαντήσεις CRL και OCSP, κανένα checkpoint pcvstOnlineRetrieval δεν σημειώνεται
Report := Pdf.ValidatePadesTrust(Options);
end;
Δύο χτίσματα αλυσίδας, δύο πεδία σφάλματος
Το backend Windows χτίζει την αλυσίδα δύο φορές, και κάθε χτίσιμο έχει πλέον τη δική του θέση σφάλματος. Η πρώτη κλήση CertGetCertificateChain τρέχει χωρίς σημαίες ανάκλησης και τροφοδοτεί το CertVerifyCertificateChainPolicy με τη βασική πολιτική, που παράγει TrustStatus και TrustError. Η δεύτερη κλήση προσθέτει τις σημαίες ανάκλησης. Πριν την v3.119.1 μια αποτυχία εκείνης της δεύτερης κλήσης έγραφε το GetLastError στο TrustError, ώστε μια αλυσίδα που μόλις είχε επαληθευτεί ως έμπιστη μπορούσε να γυρίσει φαινόμενα άπιστη επειδή ένας πάροχος ανάκλησης κόλλησε. Η διόρθωση διαβάζει το GetLastError αμέσως και το αποθηκεύει στο TPdfCmsVerifyResult.RevocationError, αφήνοντας ήσυχη την ετυμηγορία του πρώτου περάσματος. Και μια επιστροφή True από τη δεύτερη κλήση δεν αντιμετωπίζεται ούτε ως επιτυχία· σημαίνει μόνο ότι τα Windows παρέδωσαν ένα πλαίσιο αλυσίδας που αξίζει εξέταση
Τι αποδεικνύει στην πραγματικότητα μια μάσκα σφάλματος εμπιστοσύνης μηδέν;
Μόνο του, τίποτα. Ένα συγκεντρωτικό TrustStatus.dwErrorStatus μηδέν μετά το πέρασμα ανάκλησης λέει ότι δεν σηκώθηκε κανένα bit σφάλματος, και μια αλυσίδα όπου κανένα στοιχείο δεν κουβαλούσε καθόλου πληροφορία ανάκλησης μπορεί να παράγει ακριβώς αυτό. Ο παλιός κώδικας χαρτογραφούσε το «κανένα bit revoked, κανένα bit unknown, κανένα bit offline» κατευθείαν σε έγκυρο, που είναι ο κλασικός τρόπος που ένας validator αναφέρει ένα μη ελεγμένο πιστοποιητικό ως καθαρό. Η νέα ρουτίνα ReadWinRevocationEvidence περπατά κάθε απλή αλυσίδα και κάθε στοιχείο, απορρίπτει δομές των οποίων το cbSize είναι πολύ μικρό για ασφαλή ανάγνωση, και αναφέρει επιτυχία μόνο όταν υπάρχει τουλάχιστον ένα μη ριζικό στοιχείο και κάθε τέτοιο στοιχείο κουβαλάει ένα CERT_REVOCATION_INFO με dwRevocationResult μηδέν
// Συμπυκνωμένο από τον περίπατο αποδείξεων: ένα στοιχείο μετρά μόνο όταν
// ένας πάροχος ανάκλησης απάντησε πραγματικά για εκείνο
for J := 0 to ElementCount - 1 do
begin
Element := Elements[J];
ExcludedRoot := (J = ElementCount - 1) and
((Element^.TrustStatus.dwInfoStatus and
(CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
InfoPresent := (Element^.pRevocationInfo <> nil) and
(Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
if not ExcludedRoot then
begin
Inc(RequiredCount);
if not InfoPresent or
(Element^.pRevocationInfo^.dwRevocationResult <> 0) then
Complete := False;
end;
end;
Complete := Complete and (RequiredCount > 0); // μια αλυσίδα μόνο με root δεν αποδεικνύει τίποτα
Το αποτέλεσμα του πάροχου κρατιέται ακατέργαστο. Το RevocationError κρατάει το DWORD dwRevocationResult ακριβώς όπως το επέστρεψε ο πάροχος, προτιμώντας το σφάλμα από το ανακλημένο στοιχείο όταν υπάρχει (το CRYPT_E_REVOKED είναι $80092010), και η μάσκα bits εμπιστοσύνης δεν ντύνεται ποτέ ως native κωδικός σφάλματος. Η αντιστοίχιση σε TPdfCmsRevocationReason είναι σκόπιμα χονδρική: pcrrCertificateRevoked με pcvsInvalid για ρητή ανάκληση, pcrrChainUntrusted όταν η αλυσίδα απέτυχε για λόγους άσχετους με την ανάκληση, και pcrrUnknown για όλα τα άλλα. Τα Windows ίσως δοκίμασαν OCSP αντί για CRL, οπότε ένα αποτέλεσμα offline ή άγνωστο δεν μεταφράζεται σε pcrrCrlExpired. Το backend επαλήθευσης CMS OpenSSL μπορεί να κάνει εκείνες τις διακρίσεις ειδικά για CRL επειδή αξιολογεί μόνο τα CRLs που του δίνεις, ενώ το backend SecTrust του macOS αφήνει τα πεδία σε pcrrNone και μηδέν, που σημαίνει «χωρίς λεπτομερή διαγνωστική», όχι «πέρασε»
Πού σταματά ο αποκλεισμός της ρίζας
Το CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT προσπερνά νόμιμα την άγκυρα, αφού κανείς δεν δημοσιεύει CRL που ανακαλεί μια ρίζα απέναντι στον εαυτό της. Η παγίδα είναι το να αποφασίσεις ποιο στοιχείο είναι η ρίζα. Το PDFium VCL εξαιρεί το τελευταίο στοιχείο μιας απλής αλυσίδας μόνο όταν το dwInfoStatus του το σημειώνει ως self-signed ($00000008) ή ρητά CA-trusted ($00004000). Ένα offline host συχνά δεν μπορεί να ανακτήσει απόντα εκδότη, οπότε η αλυσίδα τελειώνει σε ένα intermediate· το να αντιμετωπιστεί το τελευταίο στοιχείο εκείνης της μερικής αλυσίδας ως ρίζα θα έριχνε σιωπηλά το μοναδικό πιστοποιητικό του οποίου η κατάσταση ανάκλησης είναι πιο πιθανό να απουσιάζει από την cache. Εκείνο το στοιχείο μένει στο απαιτούμενο σύνολο, δεν έχει απάντηση παρόχου, και το αποτέλεσμα μένει pcvsIndeterminate
Πώς κρατιούνται χωριστά τα αποτελέσματα ανάκλησης υπογραφής και timestamp;
Ως ξεχωριστά πεδία που δεν παρακάμπτουν ποτέ το ένα το άλλο. Ο validator PAdES επαληθεύει το αποσπασμένο CMS της υπογραφής εγγράφου και το συνημμένο CMS του token timestamp RFC 3161 σε δύο ανεξάρτητες κλήσεις, και η v3.119.0 έδωσε σε καθένα τις δικές του διαγνωστικές πάνω στο TPadesSignatureValidation: RevocationReason και NativeRevocationError για τον υπογράφοντα, TimeStampRevocationReason και NativeTimeStampRevocationError για τον TSA. Ένα ανακλημένο πιστοποιητικό TSA δεν μπορεί επομένως να πλαστοπροσωπήσει ανακλημένο υπογράφοντα, και μια αποτυχία timestamp δεν σβήνει ένα αποτέλεσμα ακεραιότητας που έχει ήδη κατασταθεί. Όταν το CheckRevocation είναι False, ή η επικύρωση δεν έφτασε ποτέ εκείνο το στάδιο, τα πεδία μένουν pcrrNone και 0, οπότε διαβάστε τα πάντοτε δίπλα στα RevocationStatus και TimeStampRevocationStatus
for I := 0 to High(Report.Signatures) do
begin
S := Report.Signatures[I];
case S.RevocationStatus of
pcsInvalid:
Log(Format('sig %d: signer revoked, provider 0x%.8x',
[I, S.NativeRevocationError]));
pcsIndeterminate:
Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
[I, Ord(S.RevocationReason), S.NativeRevocationError]));
pcsNotChecked:
Log(Format('sig %d: revocation not checked', [I]));
end;
if S.TimeStampRevocationStatus = pcsIndeterminate then
Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
[I, S.NativeTimeStampRevocationError]));
end;
Η αναφορά αποδείξεων ακολουθεί τον ίδιο κανόνα. Η εξαγωγή CSV προσαρτά το revocationReason, το nativeRevocationError και τις στήλες timestamp ως το nativeTimeStampRevocationError στο τέλος της υπάρχουσας σειράς στηλών, ώστε οι παλαιότεροι parsers να συνεχίζουν να δουλεύουν, και η εξαγωγή JSON προσθέτει αντίστοιχα πεδία χωρίς να αλλάζει τι σημαίνουν τα παλιά. Αν η offline επικύρωση εξακολουθεί να γυρνά αδιευκρίνιστη, η ανθεκτική διόρθωση είναι ανάντη: συγκεντρώστε το υλικό επαλήθευσης την ώρα της υπογραφής, όπως περιγράφεται στις μακροχρόνιες υπογραφές PDF με timestamps RFC 3161 και DSS, αντί να ελπίζετε ότι η μηχανή επαλήθευσης έχει ζεστή cache
Τι αποδεικνύει και τι δεν αποδεικνύει ο πίνακας τεστ
Ο πίνακας επικύρωσης Windows πέρασε 30 ελεγχόμενα σενάρια API αλυσίδας και ένα πραγματικό offline smoke CMS σε κάθε στόχο Win32 και Win64, Delphi και FPC. Το πραγματικό smoke επαληθεύει μια έγκυρη υπογραφή κάτω από μη έμπιστο ιδιωτικό CA, ενώ τα καθαρά και τα ρητά ανακλημένα αποτελέσματα βγαίνουν από stubbed απαντήσεις CertGetCertificateChain αντί για εγκατεστημένες άγκυρες εμπιστοσύνης ή ζωντανή ανάκτηση. Είναι ένα ειλικρινές όριο που αξίζει να δηλωθεί: ο χειρισμός σημαιών, ο αποκλεισμός σφαλμάτων και ο περίπατος αποδείξεων είναι καρφωμένοι, αλλά τι περιέχει η cache ανάκλησης μιας συγκεκριμένης μηχανής μια δεδομένη μέρα εξακολουθεί να είναι υπόθεση των Windows, και μια άδεια cache παράγει πλέον σωστά «άγνωστο» αντί για αίτημα δικτύου ή ψευδές «έγκυρο»
Ο χειρισμός offline ανάκλησης, οι διαγνωστικές ανά πεδίο και οι εξαγωγές αποδείξεων είναι μέρος του API επικύρωσης υπογραφών PDF στο PDFium VCL για Delphi και C++Builder, δίπλα στα backend OpenSSL και macOS για cross-platform αναπτύξεις