HotPDF firma un PDF utilizzando un certificato già presente nel Certificate Store di Windows, passando il digest a Windows stesso; Windows completa la richiesta tramite uno di due backend per la chiave privata: CNG, che restituisce la firma RSA in ordine big-endian, oppure il legacy CryptoAPI CSP, che la restituisce in ordine little-endian. Se si confondono i due, la firma CMS incorporata da HotPDF risulta con i byte invertiti rispetto al backend che ha effettivamente risposto, per cui un validatore conforme segnala la firma come non valida anche se i byte del documento non sono mai stati toccati
Dietro quella singola frase si nascondono due problemi non correlati, ed entrambi devono essere risolti dal signer per certificati di sistema di HotPDF prima ancora che possa firmare qualsiasi cosa. La discrepanza nell'ordine dei byte è silenziosa: la chiamata di firma restituisce comunque True, il PDF si apre comunque, e il fallimento emerge solo quando un visualizzatore analizza la struttura CMS e la rifiuta. Il secondo problema è invece evidente ed è specifico di C++Builder: una mezza dozzina di funzioni di crypt32 si rifiutano di linkarsi, perché la import library fornita da RAD Studio non le esporta. Nessuno dei due problemi si presenta se si firma solo con un file PFX, motivo per cui tende a colpire gli sviluppatori che passano dalla firma one-call basata su PFX a un certificato già installato dal reparto IT nel profilo dell'utente
Selezionare un certificato dallo store
HotPDF espone questo percorso tramite HPDFSignPDFStreamWithSystemCertificate e HPDFSignPDFFileWithSystemCertificate, entrambi guidati da un record THPDFCertificateStoreSelector: Location (cslCurrentUser oppure cslLocalMachine), StoreName ('MY', lo store personale, di default), un Thumbprint SHA-1 e un flag AllowUI. Il thumbprint viene normalizzato internamente, quindi trattini o spazi copiati direttamente dall'interfaccia di Certificate Manager vengono rimossi prima che venga eseguito il confronto
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 conta più di quanto sembri, perché viene mappato direttamente su CRYPT_ACQUIRE_SILENT_FLAG, e Windows lo rispetta alla lettera: se la chiave privata del certificato trovato risiede su una smart card o un token che richiede un prompt PIN non già memorizzato nella cache di Windows, CryptAcquireCertificatePrivateKey fallisce invece di mostrare una finestra di dialogo da quello che potrebbe essere un processo di servizio. Questo fallimento è evidente, un EHPDFCMSError che si vede immediatamente, ma è facile fraintenderlo come "certificato non trovato" quando la causa reale è un token in attesa di un PIN che nessuno digiterà mai
Perché CNG e CAPI non concordano sull'ordine dei byte?
Quale backend risponde non è una supposizione: CryptAcquireCertificatePrivateKey lo comunica direttamente tramite un parametro di output KeySpec, ed è proprio questo valore su cui il signer di HotPDF si basa per decidere il ramo da seguire. Una chiave del CNG Key Storage Provider torna con KeySpec impostato al valore sentinella CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); qualsiasi altro valore indica una chiave CSP CryptoAPI tradizionale. La maggior parte dei certificati personali emessi o importati su un'installazione Windows attuale si risolve in CNG, anche se per compatibilità esiste ancora uno shim CSP legacy, motivo per cui HotPDF richiede CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG insieme a CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG prima di verificare quale valore sia stato restituito
I due backend non si limitano a chiamare funzioni diverse, NCryptSignHash per una chiave CNG, CryptSignHashA per una chiave CSP; restituiscono anche la firma RSA grezza in ordine di byte opposto. L'output di CNG corrisponde già a quanto richiesto da PKCS#1: una stringa di ottetti big-endian, con il byte più significativo per primo, esattamente ciò che produce la conversione I2OSP dell'RFC 8017 e ciò di cui un SignerInfo CMS (RFC 5652) ha bisogno nel proprio campo signature secondo ISO 32000-1 §12.8.3. Il CryptSignHash di CryptoAPI, al contrario, restituisce la firma in ordine little-endian, una particolarità documentata che risale al modo in cui i CSP classici rappresentavano internamente i numeri grandi. Se si omette l'inversione sul percorso CAPI, ogni byte della firma finisce nel posto sbagliato; la matematica RSA resta corretta, ma la stringa di ottetti che un verificatore legge non è quella definita da 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;
E per un callback di firma personalizzato?
Chiunque bypassi il signer integrato per il certificate store di HotPDF eredita la stessa regola sull'ordine dei byte. HPDFCMSSignPDFStreamWithExternalSigner accetta un THPDFCMSSignDigestCallback, una closure di tipo reference to function(const SignedAttributesSHA256: TBytes): TBytes, per firmare tramite un HSM, uno stack middleware per smart card o qualsiasi altra cosa che non sia un certificato per cui lo store di Windows possa fornire un handle di chiave. Qualunque sia il backend dietro quel callback, i byte che restituisce devono arrivare in ordine big-endian prima che HotPDF li incorpori nella struttura 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);
Vale la pena chiarire un confine qui: i due percorsi di firma integrati di HotPDF, CNG tramite NCryptSignHash con padding PKCS#1 e CAPI tramite CryptSignHashA, mirano entrambi a chiavi RSA che firmano un digest SHA-256 di 32 byte. Nessuno dei due negozia un formato di firma ECDSA. Un certificato la cui chiave privata è basata su curve ellittiche (EC) richiede un signer scritto autonomamente tramite HPDFCMSSignPDFStreamWithExternalSigner, codificando la firma ECDSA nel modo atteso da CMS anziché presupporre una stringa di byte RSA a lunghezza fissa; quindi non aspettarsi che il signer integrato per il certificate store si comporti correttamente con un token dotato di certificato EC
Perché C++Builder non riesce a linkare CertOpenStore?
Perché la import library predefinita di C++Builder in RAD Studio, import32.lib, non esporta CertOpenStore né cinque delle sue funzioni vicine: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore e CryptAcquireCertificatePrivateKey. Le build Delphi non incontrano mai questo problema, perché dcc32/dcc64 risolvono un import statico external 'crypt32.dll' direttamente nella import table del PE. C++Builder è diverso: il compilatore Delphi emette un .obj in formato OMF per la build del package, ilink32 lo linka, e a quel punto la stessa dichiarazione external è semplicemente un simbolo non risolto in attesa di una import library sulla riga di comando. Puntare il linker verso la directory psdk del Windows SDK, dove il crypt32.lib completo esporta tutti e sei i simboli, non risolve comunque il problema: ilink32 linka solo le import library effettivamente indicate nella sua riga di comando, import32.lib cp32mt.lib per default, e l'aggiunta di un percorso di ricerca non fa sì che venga prelevato nient'altro da quel percorso. Eseguire tdump su import32.lib conferma direttamente il vuoto: zero corrispondenze per CertOpenStore, contro sei corrispondenze pulite nel crypt32.lib dell'SDK
HotPDF risolve il problema nello stesso modo in cui già gestisce l'enumerazione dei certificati altrove nella libreria: invece di chiedere questi simboli al linker, li carica a runtime. Un record interno THPDFCryptoProcs contiene un handle a crypt32.dll, un handle a advapi32.dll e undici campi puntatore a funzione; LoadCryptoProcs carica entrambe le DLL e risolve ogni entry point con GetProcAddress esattamente una volta, all'inizio di HPDFSignPDFStreamWithSystemCertificate, sollevando immediatamente EHPDFCMSError se manca qualcosa, invece di fallire più tardi con un access violation in profondità nel flusso di firma
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;
Il caricamento avviene una sola volta per chiamata anziché in modo lazy dentro ciascun helper, perché la closure che sceglie tra CNG e CAPI cattura per valore la tabella delle funzioni caricate e deve restare viva per l'intero flusso di firma, incluso il callback verso HPDFCMSSignPDFStreamWithExternalSigner; entrambi gli handle delle DLL vengono liberati nel blocco finally più esterno una volta che la firma termina o solleva un'eccezione. Nulla di tutto ciò tocca la superficie pubblica: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate e THPDFCertificateStoreSelector mantengono esattamente le stesse firme di prima, quindi recepire la correzione richiede solo una ricompilazione per il codice chiamante esistente, non una modifica al codice
Cosa non copre questo articolo
Ottenere il corretto ordine dei byte e risolvere il link di C++Builder produce un SignerInfo CMS che un validatore può analizzare e una firma che può verificare aritmeticamente; questo non dice nulla su se quel validatore debba fidarsi del certificato che vi sta dietro, poiché la costruzione della catena, il controllo della revoca e la politica di timestamp sono questioni separate che si aggiungono sopra tramite le opzioni CMS, non qualcosa che la correttezza dell'ordine dei byte garantisce gratuitamente. Due dettagli di gestione contano quanto la crittografia: il PCCERT_CONTEXT restituito dalla ricerca del certificato deve essere liberato con CertFreeCertificateContext prima che lo store si chiuda, e un handle di chiave CNG o CSP acquisito, quando l'API segnala che è il chiamante a possederlo, deve essere rilasciato tramite la chiamata propria del backend corrispondente, mai quella dell'altro. Se il risultato svValid che si ottiene dopo tutto questo risulta più limitato di quanto ci si aspettasse, l'articolo sulla verifica delle firme digitali PDF illustra esattamente cosa quel flag promette e cosa no. Poiché qui il certificato resta per tutto il tempo in custodia di Windows, la firma dal certificate store evita un'intera superficie di attacco: non c'è alcun file PKCS#12 da analizzare né ASN.1 da percorrere manualmente, che è il problema affrontato invece dall'hardening PKCS#12 e ASN.1 di HotPDF per il percorso di firma tramite file PFX
La firma dal certificate store, la firma PFX e i callback per signer esterni sono tre porte d'accesso alla stessa pipeline CMS/PKCS#7 all'interno del componente PDF HotPDF per Delphi e C++Builder, e la scelta di quella giusta dipende soprattutto da chi è autorizzato a detenere la chiave privata: il proprio processo, un file PFX, oppure Windows stesso