Artikel Teknis

HotPDF Cert Store PDF Signing: Urutan Byte CNG vs CAPI

HotPDF menandatangani PDF terhadap sertifikat yang sudah tersimpan di Windows Certificate Store dengan menyerahkan digest ke Windows sendiri, dan Windows menyelesaikan permintaan itu lewat salah satu dari dua backend private-key: CNG, yang mengembalikan signature RSA dalam urutan big-endian, atau CryptoAPI CSP lama, yang mengembalikannya dalam urutan little-endian. Kalau kedua hal ini tertukar, signature CMS yang disematkan HotPDF akan terbalik byte-nya sesuai backend mana pun yang sebenarnya menjawab, sehingga validator yang sesuai standar akan melaporkan signature sebagai tidak valid padahal byte dokumen sama sekali tidak pernah disentuh

Ada dua masalah yang tidak saling berkaitan tersembunyi di balik satu kalimat itu, dan signer sertifikat sistem milik HotPDF harus menyelesaikan keduanya sebelum bisa menandatangani apa pun. Ketidakcocokan urutan byte ini bersifat diam-diam: pemanggilan signing tetap mengembalikan True, PDF tetap bisa dibuka, dan kegagalan baru terlihat ketika sebuah viewer menelusuri struktur CMS dan menolaknya. Masalah kedua justru mencolok dan spesifik untuk C++Builder: setengah lusin fungsi crypt32 menolak untuk di-link, karena import library yang disertakan RAD Studio tidak mengekspor fungsi-fungsi tersebut. Kedua masalah ini tidak akan muncul kalau Anda hanya pernah menandatangani dengan file PFX, itulah sebabnya masalah ini cenderung menjebak developer yang berpindah dari signing satu-panggilan berbasis PFX ke sertifikat yang sudah dipasang tim IT di profil pengguna

Memilih sertifikat dari store

HotPDF mengekspos jalur ini sebagai HPDFSignPDFStreamWithSystemCertificate dan HPDFSignPDFFileWithSystemCertificate, keduanya dikendalikan oleh record THPDFCertificateStoreSelector: Location (cslCurrentUser atau cslLocalMachine), StoreName ('MY', personal store, secara default), Thumbprint SHA-1, dan flag AllowUI. Thumbprint dinormalisasi secara internal, sehingga tanda hubung atau spasi yang tersalin langsung dari UI Certificate Manager akan dihapus sebelum perbandingan dijalankan

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 punya dampak lebih besar dari yang terlihat, karena flag ini langsung dipetakan ke CRYPT_ACQUIRE_SILENT_FLAG, dan Windows mematuhinya secara harfiah: jika private key dari sertifikat yang cocok berada di smart card atau token yang butuh prompt PIN yang belum di-cache Windows, CryptAcquireCertificatePrivateKey akan gagal alih-alih memunculkan dialog dari proses yang mungkin saja sebuah service. Kegagalan itu mencolok, berupa EHPDFCMSError yang langsung terlihat, tetapi mudah disalahartikan sebagai "sertifikat tidak ditemukan" padahal penyebab sebenarnya adalah token yang menunggu PIN yang tidak akan pernah diketikkan siapa pun

Mengapa CNG dan CAPI tidak sepakat soal urutan byte?

Backend mana yang menjawab bukan sekadar tebakan: CryptAcquireCertificatePrivateKey melaporkannya langsung lewat out-parameter KeySpec, dan nilai tunggal itulah yang menjadi dasar percabangan pada signer HotPDF. Key dari CNG Key Storage Provider akan dikembalikan dengan KeySpec diset ke sentinel CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); selain itu adalah key CryptoAPI CSP tradisional. Sebagian besar sertifikat personal yang diterbitkan atau diimpor pada instalasi Windows terkini akan mengarah ke CNG walaupun shim CSP lama masih ada demi kompatibilitas, itulah sebabnya HotPDF meminta CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG bersama CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG sebelum memeriksa nilai apa yang dikembalikan

Kedua backend ini bukan cuma memanggil fungsi berbeda, NCryptSignHash terhadap key CNG, CryptSignHashA terhadap key CSP; keduanya juga mengembalikan signature RSA mentah dalam urutan byte yang berlawanan. Output CNG sudah sesuai dengan apa yang diharapkan PKCS#1: octet string big-endian, byte paling signifikan lebih dulu, persis seperti yang dihasilkan konversi I2OSP milik RFC 8017 dan yang dibutuhkan field signature CMS SignerInfo (RFC 5652) di bawah ISO 32000-1 §12.8.3. Sebaliknya, CryptSignHash milik CryptoAPI mengembalikan signature dalam urutan little-endian, sebuah keunikan yang sudah terdokumentasi dan berakar dari cara CSP klasik merepresentasikan bilangan besar secara internal. Kalau langkah pembalikan pada jalur CAPI dilewatkan, setiap byte dalam signature akan berada di posisi yang salah; matematika RSA-nya tetap benar, tetapi octet string yang dibaca verifier bukan lagi yang didefinisikan 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;

Bagaimana dengan callback signer kustom?

Siapa pun yang melewati signer cert-store bawaan HotPDF tetap mewarisi aturan urutan byte yang sama. HPDFCMSSignPDFStreamWithExternalSigner menerima THPDFCMSSignDigestCallback, sebuah closure bertipe reference to function(const SignedAttributesSHA256: TBytes): TBytes, untuk signing lewat HSM, stack middleware smart-card, atau apa pun lain yang bukan sertifikat yang bisa diberikan Windows store lewat handle key. Backend apa pun yang berada di balik callback tersebut, byte yang dikembalikannya harus berada dalam urutan big-endian sebelum HotPDF melipatnya ke dalam struktur 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);

Ada satu batasan yang perlu dijelaskan secara eksplisit di sini: kedua jalur signing bawaan HotPDF, CNG lewat NCryptSignHash dengan padding PKCS#1 dan CAPI lewat CryptSignHashA, keduanya menargetkan key RSA yang menandatangani digest SHA-256 sepanjang 32 byte. Tak satu pun dari keduanya menegosiasikan format signature ECDSA. Sertifikat yang private key-nya berbasis EC membutuhkan signer yang Anda tulis sendiri lewat HPDFCMSSignPDFStreamWithExternalSigner, dengan meng-encode signature ECDSA sesuai yang diharapkan CMS alih-alih mengasumsikan byte string RSA dengan panjang tetap, jadi jangan berharap signer cert-store bawaan berperilaku benar untuk token yang diprovisikan dengan sertifikat EC

Mengapa C++Builder gagal me-link CertOpenStore?

Karena import library default C++Builder milik RAD Studio, import32.lib, tidak mengekspor CertOpenStore, atau lima fungsi tetangganya: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, dan CryptAcquireCertificatePrivateKey. Build Delphi tidak pernah mengalami ini, karena dcc32/dcc64 langsung menyelesaikan import statis external 'crypt32.dll' ke dalam import table PE. C++Builder berbeda: compiler Delphi menghasilkan .obj OMF untuk build package, ilink32 me-link-nya, dan pada titik itu deklarasi external yang sama hanyalah simbol tak terselesaikan yang menunggu import library di command line. Mengarahkan linker ke direktori psdk milik Windows SDK, tempat crypt32.lib lengkap memang mengekspor keenam simbol tersebut, tetap tidak memperbaikinya: ilink32 hanya me-link import library yang benar-benar disebutkan di command line-nya, secara default import32.lib cp32mt.lib, dan menambahkan search path tidak membuatnya menarik apa pun tambahan dari path itu. Menjalankan tdump terhadap import32.lib langsung mengonfirmasi celah ini, nol hasil untuk CertOpenStore, dibandingkan enam hasil bersih pada crypt32.lib milik SDK

HotPDF menyelesaikan ini dengan cara yang sama seperti yang sudah dilakukannya di tempat lain di library untuk enumerasi sertifikat: alih-alih meminta simbol-simbol ini ke linker, HotPDF memuatnya saat runtime. Sebuah record internal THPDFCryptoProcs membawa handle crypt32.dll, handle advapi32.dll, dan sebelas field function-pointer; LoadCryptoProcs memuat kedua DLL dan menyelesaikan setiap entry point dengan GetProcAddress tepat satu kali, di awal HPDFSignPDFStreamWithSystemCertificate, langsung memunculkan EHPDFCMSError jika ada yang hilang alih-alih gagal belakangan dengan access violation jauh di dalam alur signing

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;

Pemuatan dilakukan sekali per panggilan alih-alih malas-malasan di dalam setiap helper, karena closure yang memilih antara CNG dan CAPI menangkap tabel fungsi yang sudah dimuat itu secara by-value dan harus tetap hidup sepanjang alur signing, termasuk callback ke HPDFCMSSignPDFStreamWithExternalSigner; kedua handle DLL dibebaskan di blok finally paling luar begitu signing selesai atau memunculkan exception. Tak satu pun dari ini menyentuh permukaan publik: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, dan THPDFCertificateStoreSelector tetap mempertahankan signature persis seperti sebelumnya, sehingga mendapatkan perbaikan ini cukup dengan rebuild bagi pemanggil yang sudah ada, bukan perubahan kode

Apa yang tidak dicakup di sini

Membenahi urutan byte dan link C++Builder menghasilkan CMS SignerInfo yang bisa di-parse validator dan signature yang bisa diperiksa secara aritmetika; itu sama sekali tidak menjawab apakah validator tersebut seharusnya memercayai sertifikat di baliknya, karena chain building, pengecekan revocation, dan kebijakan timestamp adalah hal-hal terpisah yang dilapiskan di atasnya lewat opsi CMS, bukan sesuatu yang otomatis didapat dari kebenaran urutan byte. Dua detail housekeeping sama pentingnya dengan kriptografinya: PCCERT_CONTEXT yang dikembalikan oleh pencarian sertifikat harus dibebaskan dengan CertFreeCertificateContext sebelum store ditutup, dan handle key CNG atau CSP yang diperoleh, ketika API melaporkan pemanggil yang memilikinya, harus dilepaskan lewat panggilan milik backend yang sesuai, tidak pernah lewat backend yang lain. Jika hasil svValid yang Anda dapatkan setelah semua ini ternyata lebih sempit dari yang diharapkan, artikel tentang memverifikasi signature digital PDF menjelaskan persis apa yang dijanjikan flag tersebut dan apa yang tidak. Karena sertifikat tetap berada dalam kendali Windows sepanjang waktu di sini, cert-store signing menghindari seluruh permukaan serangan: tidak ada file PKCS#12 yang perlu di-parse dan tidak ada ASN.1 yang perlu ditelusuri sendiri, yang justru menjadi masalah yang diatasi hardening PKCS#12 dan ASN.1 milik HotPDF untuk jalur signing berbasis file PFX

Cert-store signing, PFX signing, dan callback external-signer adalah tiga pintu menuju pipeline CMS/PKCS#7 yang sama di dalam komponen PDF HotPDF untuk Delphi dan C++Builder, dan memilih pintu yang tepat sebagian besar bergantung pada siapa yang boleh memegang private key: proses Anda sendiri, file PFX, atau Windows itu sendiri