Το HotPDF υπογράφει ένα PDF έναντι ενός πιστοποιητικού που βρίσκεται ήδη στο Windows Certificate Store παραδίδοντας το digest στα ίδια τα Windows, τα οποία ολοκληρώνουν το αίτημα μέσω ενός από δύο backend ιδιωτικού κλειδιού: του CNG, που επιστρέφει την υπογραφή RSA σε μορφή big-endian, ή του παλαιότερου CryptoAPI CSP, που την επιστρέφει σε μορφή little-endian. Αν τα δύο μπερδευτούν, η υπογραφή CMS που ενσωματώνει το HotPDF έχει αντεστραμμένα byte για όποιο backend πραγματικά απάντησε, με αποτέλεσμα ένας συμμορφούμενος validator να αναφέρει την υπογραφή ως άκυρη, παρότι τα bytes του εγγράφου ουδέποτε πειράχτηκαν
Πίσω από αυτή τη μία πρόταση κρύβονται δύο ασύνδετα προβλήματα, και ο υπογράφων συστήματος πιστοποιητικών του HotPDF πρέπει να λύσει και τα δύο πριν υπογράψει οτιδήποτε. Η ασυμφωνία σειράς byte είναι σιωπηλή: η κλήση υπογραφής εξακολουθεί να επιστρέφει True, το PDF εξακολουθεί να ανοίγει, και η αποτυχία εμφανίζεται μόνο όταν ένας viewer διατρέχει τη δομή CMS και την απορρίπτει. Το δεύτερο πρόβλημα είναι εμφανές και ειδικό για το C++Builder: μισή ντουζίνα συναρτήσεις της crypt32 αρνούνται να συνδεθούν, επειδή η βιβλιοθήκη εισαγωγής που διανέμει το RAD Studio δεν τις εξάγει. Κανένα από τα δύο προβλήματα δεν εμφανίζεται αν υπογράφετε πάντα με αρχείο PFX, γι' αυτό και τείνει να πιάνει στον ύπνο προγραμματιστές που μεταβαίνουν από την υπογραφή μίας κλήσης βασισμένη σε PFX σε ένα πιστοποιητικό που έχει ήδη εγκαταστήσει ένα τμήμα IT στο προφίλ του χρήστη
Επιλογή πιστοποιητικού από το store
Το HotPDF εκθέτει αυτή τη διαδρομή ως HPDFSignPDFStreamWithSystemCertificate και HPDFSignPDFFileWithSystemCertificate, και τα δύο ελεγχόμενα από μια εγγραφή THPDFCertificateStoreSelector: Location (cslCurrentUser ή cslLocalMachine), StoreName ('MY', το προσωπικό store, εξ ορισμού), ένα SHA-1 Thumbprint, και μια σημαία AllowUI. Το thumbprint κανονικοποιείται εσωτερικά, οπότε παύλες ή κενά που αντιγράφηκαν απευθείας από το περιβάλλον εργασίας του Certificate Manager αφαιρούνται πριν εκτελεστεί η σύγκριση
var
Selector: THPDFCertificateStoreSelector;
Options: THPDFCMSSignOptions;
begin
Selector := THPDFCertificateStoreSelector.Default; // cslCurrentUser, store 'MY'
Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
Selector.AllowUI := False;
Options := HPDFCMSDefaultOptions(palBaseline_B_B);
if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
'invoice-signed.pdf', Selector, Options) then
raise Exception.Create('Certificate-store signing failed');
end;
Το AllowUI = False έχει μεγαλύτερη σημασία απ' όσο φαίνεται, επειδή αντιστοιχίζεται απευθείας στο CRYPT_ACQUIRE_SILENT_FLAG, και τα Windows το τηρούν κατά γράμμα: αν το ιδιωτικό κλειδί του πιστοποιητικού που ταιριάζει βρίσκεται σε smart card ή token που απαιτεί προτροπή PIN που τα Windows δεν έχουν ήδη αποθηκεύσει στην κρυφή μνήμη, το CryptAcquireCertificatePrivateKey αποτυγχάνει αντί να εμφανίσει διάλογο από μια διεργασία που ενδέχεται να είναι υπηρεσία. Αυτή η αποτυχία είναι εμφανής, ένα EHPDFCMSError που βλέπετε αμέσως, αλλά είναι εύκολο να παρερμηνευτεί ως "πιστοποιητικό δεν βρέθηκε" όταν η πραγματική αιτία είναι ένα token που περιμένει ένα PIN που κανείς δεν πρόκειται να πληκτρολογήσει
Γιατί διαφωνούν το CNG και το CAPI στη σειρά byte;
Ποιο backend απαντά δεν είναι εικασία: το CryptAcquireCertificatePrivateKey το αναφέρει απευθείας μέσω μιας παραμέτρου εξόδου KeySpec, και αυτή ακριβώς η τιμή είναι εκείνη στην οποία διακλαδίζεται ο υπογράφων του HotPDF. Ένα κλειδί CNG Key Storage Provider επιστρέφει με το KeySpec ορισμένο στη φρουρική τιμή CERT_NCRYPT_KEY_SPEC ($FFFFFFFF)· οτιδήποτε άλλο είναι παραδοσιακό κλειδί CryptoAPI CSP. Τα περισσότερα προσωπικά πιστοποιητικά που εκδίδονται ή εισάγονται σε μια τρέχουσα εγκατάσταση Windows καταλήγουν σε CNG, παρότι εξακολουθεί να υπάρχει ένα shim παλαιού τύπου CSP για συμβατότητα, γι' αυτό το HotPDF ζητά CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG μαζί με CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG προτού εξετάσει ποια τιμή επιστράφηκε
Τα δύο backend δεν καλούν απλώς διαφορετικές συναρτήσεις, NCryptSignHash έναντι κλειδιού CNG, CryptSignHashA έναντι κλειδιού CSP· επιστρέφουν την ακατέργαστη υπογραφή RSA σε αντίθετη σειρά byte. Η έξοδος του CNG ήδη ταιριάζει με ό,τι αναμένει το PKCS#1: μια big-endian octet string, με το πιο σημαντικό byte πρώτο, ακριβώς αυτό που παράγει η μετατροπή I2OSP του RFC 8017 και αυτό που χρειάζεται ένα CMS SignerInfo (RFC 5652) στο πεδίο υπογραφής του, σύμφωνα με το ISO 32000-1 §12.8.3. Το CryptSignHash του CryptoAPI, αντιθέτως, επιστρέφει την υπογραφή little-endian, μια τεκμηριωμένη ιδιαιτερότητα που ανάγεται στον τρόπο που τα κλασικά CSP αναπαριστούσαν εσωτερικά μεγάλους αριθμούς. Αν παραλείψετε την αντιστροφή στη διαδρομή CAPI, κάθε byte της υπογραφής καταλήγει στη λάθος θέση· τα μαθηματικά RSA παραμένουν σωστά, αλλά η octet string που διαβάζει ένας επαληθευτής δεν είναι αυτή που ορίζει το PKCS#1
// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
Temp := Signature[I];
Signature[I] := Signature[High(Signature) - I];
Signature[High(Signature) - I] := Temp;
end;
Τι γίνεται με ένα προσαρμοσμένο callback υπογραφής;
Όποιος παρακάμπτει τον ενσωματωμένο υπογράφοντα του HotPDF από το certificate store κληρονομεί τον ίδιο κανόνα σειράς byte. Το HPDFCMSSignPDFStreamWithExternalSigner δέχεται ένα THPDFCMSSignDigestCallback, ένα closure τύπου reference to function(const SignedAttributesSHA256: TBytes): TBytes, για υπογραφή μέσω HSM, στοίβας middleware smart-card, ή οτιδήποτε άλλο δεν είναι πιστοποιητικό για το οποίο το Windows store μπορεί να σας δώσει χειριστή κλειδιού. Όποιο backend κι αν βρίσκεται πίσω από αυτό το callback, τα bytes που επιστρέφει πρέπει να καταλήξουν σε σειρά big-endian προτού το HotPDF τα ενσωματώσει στη δομή CMS
Signer :=
function(const SignedAttributesSHA256: TBytes): TBytes
begin
if UsesCngKeyStorageProvider then
Result := SignWithMyCngKey(SignedAttributesSHA256) // already big-endian
else
Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
CertificateDER, Signer, Options);
Αξίζει να διευκρινιστεί εδώ ένα όριο: οι δύο ενσωματωμένες διαδρομές υπογραφής του HotPDF, το CNG μέσω NCryptSignHash με padding PKCS#1 και το CAPI μέσω CryptSignHashA, στοχεύουν και τα δύο κλειδιά RSA που υπογράφουν ένα digest SHA-256 32 byte. Κανένα δεν διαπραγματεύεται μορφή υπογραφής ECDSA. Ένα πιστοποιητικό του οποίου το ιδιωτικό κλειδί βασίζεται σε EC χρειάζεται έναν υπογράφοντα που γράφετε εσείς οι ίδιοι έναντι του HPDFCMSSignPDFStreamWithExternalSigner, κωδικοποιώντας την υπογραφή ECDSA με τον τρόπο που αναμένει το CMS αντί να υποθέτετε μια σταθερού μήκους αλυσίδα byte RSA, οπότε μην περιμένετε ο ενσωματωμένος υπογράφων certificate store να κάνει το σωστό για ένα token εφοδιασμένο με πιστοποιητικό EC
Γιατί το C++Builder αποτυγχάνει να συνδέσει το CertOpenStore;
Επειδή η προεπιλεγμένη βιβλιοθήκη εισαγωγής C++Builder του RAD Studio, import32.lib, δεν εξάγει το CertOpenStore, ούτε πέντε γείτονές του: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, και CryptAcquireCertificatePrivateKey. Τα builds Delphi δεν αντιμετωπίζουν ποτέ αυτό το πρόβλημα, επειδή οι dcc32/dcc64 επιλύουν μια στατική δήλωση εισαγωγής external 'crypt32.dll' απευθείας στον πίνακα εισαγωγών PE. Το C++Builder είναι διαφορετικό: ο compiler Delphi εκπέμπει ένα OMF .obj για το build του πακέτου, το ilink32 το συνδέει, και εκεί η ίδια δήλωση external είναι απλώς ένα ανεπίλυτο σύμβολο που περιμένει μια βιβλιοθήκη εισαγωγής στη γραμμή εντολών. Το να στρέψετε τον linker στον κατάλογο psdk του Windows SDK, όπου η πλήρης crypt32.lib εξάγει και τα έξι σύμβολα, δεν το διορθώνει επίσης: το ilink32 συνδέει μόνο τις βιβλιοθήκες εισαγωγής που ονομάζονται πράγματι στη γραμμή εντολών του, import32.lib cp32mt.lib εξ ορισμού, και η προσθήκη μιας διαδρομής αναζήτησης δεν το κάνει να τραβήξει κάτι επιπλέον από αυτή τη διαδρομή. Η εκτέλεση του tdump έναντι του import32.lib επιβεβαιώνει άμεσα το κενό, μηδέν χτυπήματα για το CertOpenStore, έναντι έξι καθαρών χτυπημάτων στο crypt32.lib του SDK
Το HotPDF το επιλύει με τον ίδιο τρόπο που ήδη διαχειρίζεται την απαρίθμηση πιστοποιητικών αλλού στη βιβλιοθήκη: αντί να ζητά από τον linker αυτά τα σύμβολα, τα φορτώνει κατά τον χρόνο εκτέλεσης. Μια εσωτερική εγγραφή THPDFCryptoProcs κρατά έναν χειριστή crypt32.dll, έναν χειριστή advapi32.dll, και έντεκα πεδία δεικτών συνάρτησης· το LoadCryptoProcs φορτώνει και τα δύο DLL και επιλύει κάθε σημείο εισόδου με το GetProcAddress ακριβώς μία φορά, στην αρχή του HPDFSignPDFStreamWithSystemCertificate, εγείροντας αμέσως EHPDFCMSError αν κάτι λείπει, αντί να αποτύχει αργότερα με παραβίαση πρόσβασης βαθιά μέσα στη ροή υπογραφής
type
TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
Crypt32Handle: HMODULE;
CertOpenStore: TCertOpenStoreFn;
begin
Crypt32Handle := LoadLibrary('crypt32.dll');
if Crypt32Handle = 0 then
raise Exception.Create('crypt32.dll could not be loaded');
@CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
// ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;
Η φόρτωση γίνεται μία φορά ανά κλήση αντί για οκνηρά μέσα σε κάθε βοηθητική συνάρτηση, επειδή το closure που επιλέγει μεταξύ CNG και CAPI συλλαμβάνει τον φορτωμένο πίνακα συναρτήσεων κατά τιμή και πρέπει να παραμείνει ενεργό για ολόκληρη τη ροή υπογραφής, συμπεριλαμβανομένου του callback στο HPDFCMSSignPDFStreamWithExternalSigner· και οι δύο χειριστές DLL απελευθερώνονται στο εξωτερικότερο μπλοκ finally μόλις η υπογραφή ολοκληρωθεί ή εγείρει εξαίρεση. Τίποτα από αυτά δεν αγγίζει τη δημόσια επιφάνεια: τα HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, και THPDFCertificateStoreSelector διατηρούν ακριβώς τις ίδιες υπογραφές που είχαν πριν, οπότε η υιοθέτηση της διόρθωσης είναι απλώς ένα rebuild για τους υπάρχοντες καλούντες, όχι αλλαγή κώδικα
Τι δεν καλύπτει αυτό
Η σωστή σειρά byte και η σωστή σύνδεση στο C++Builder παράγουν ένα CMS SignerInfo που ένας validator μπορεί να αναλύσει και μια υπογραφή που μπορεί να ελέγξει αριθμητικά· δεν λέει τίποτα για το αν αυτός ο validator πρέπει να εμπιστευτεί το πιστοποιητικό πίσω από αυτήν, καθώς η κατασκευή αλυσίδας, ο έλεγχος ανάκλησης, και η πολιτική χρονοσφραγίδας είναι ξεχωριστά ζητήματα που στρώνονται από πάνω μέσω των επιλογών CMS, όχι κάτι που αγοράζει δωρεάν η σωστή σειρά byte. Δύο λεπτομέρειες οικονομίας έχουν εξίσου μεγάλη σημασία με την κρυπτογραφία: το PCCERT_CONTEXT που επιστρέφεται από την αναζήτηση πιστοποιητικού πρέπει να απελευθερωθεί με CertFreeCertificateContext πριν κλείσει το store, και ένας αποκτηθείς χειριστής κλειδιού CNG ή CSP, όταν το API αναφέρει ότι ο καλών τον κατέχει, πρέπει να απελευθερωθεί μέσω της αντίστοιχης κλήσης του δικού του backend, ποτέ του άλλου. Αν το αποτέλεσμα svValid που παίρνετε πίσω μετά από όλα αυτά αποδειχθεί πιο περιορισμένο απ' ό,τι περιμένατε, το άρθρο για την επαλήθευση ψηφιακών υπογραφών PDF εξηγεί ακριβώς τι υπόσχεται και τι όχι αυτή η σημαία. Επειδή το πιστοποιητικό παραμένει σε φύλαξη των Windows καθ' όλη τη διάρκεια εδώ, η υπογραφή από certificate store παρακάμπτει ολόκληρη μια επιφάνεια επίθεσης: δεν υπάρχει αρχείο PKCS#12 προς ανάλυση ούτε ASN.1 προς διάτρεξη μόνοι σας, κάτι που αντιμετωπίζει η ενίσχυση ασφάλειας PKCS#12 και ASN.1 του HotPDF για τη διαδρομή υπογραφής μέσω αρχείου PFX
Η υπογραφή από certificate store, η υπογραφή PFX, και τα callback εξωτερικού υπογράφοντα είναι τρεις πόρτες προς την ίδια διοχέτευση CMS/PKCS#7 μέσα στο εξάρτημα HotPDF PDF για Delphi και C++Builder, και η επιλογή της σωστής εξαρτάται κυρίως από το ποιος επιτρέπεται να κρατά το ιδιωτικό κλειδί: η διεργασία σας, ένα αρχείο PFX, ή τα ίδια τα Windows