บทความเทคนิค

การเซ็น PDF จาก Certificate Store ด้วย HotPDF: ลำดับไบต์ CNG เทียบกับ CAPI

HotPDF เซ็น PDF โดยอ้างอิงใบรับรอง (certificate) ที่มีอยู่แล้วใน Windows Certificate Store ด้วยการส่ง digest ไปให้ Windows จัดการเอง และ Windows จะทำงานให้เสร็จผ่าน private-key backend หนึ่งในสองแบบ ได้แก่ CNG ซึ่งคืนค่าลายเซ็น RSA แบบ big-endian หรือ CryptoAPI CSP แบบดั้งเดิม ซึ่งคืนค่าแบบ little-endian หากสับสนระหว่างสองแบบนี้ ลายเซ็น CMS ที่ HotPDF ฝังเข้าไปจะมีลำดับไบต์กลับด้านตาม backend ที่ตอบกลับมาจริง ทำให้ validator ที่ทำงานตามมาตรฐานรายงานว่าลายเซ็นไม่ถูกต้อง ทั้งที่ไบต์ของเอกสารไม่เคยถูกแตะต้องเลย

ปัญหาสองเรื่องที่ไม่เกี่ยวข้องกันซ่อนอยู่หลังประโยคเดียวนั้น และตัวเซ็นใบรับรองระบบ (system-certificate signer) ของ HotPDF ต้องแก้ทั้งคู่ก่อนจะเซ็นอะไรได้เลย ปัญหาเรื่องลำดับไบต์นั้นเงียบมาก การเรียกฟังก์ชันเซ็นยังคืนค่า True ตามปกติ ไฟล์ PDF ยังเปิดได้ตามปกติ และความล้มเหลวจะปรากฏก็ต่อเมื่อโปรแกรมดู PDF เดินตรวจโครงสร้าง CMS แล้วปฏิเสธมันเท่านั้น ส่วนปัญหาที่สองนั้นชัดเจนและเจาะจงเฉพาะ C++Builder ฟังก์ชันใน crypt32 ราวครึ่งโหลปฏิเสธที่จะ link เพราะ import library ที่ RAD Studio แถมมาไม่ export ฟังก์ชันเหล่านั้น ปัญหาทั้งสองนี้จะไม่เกิดขึ้นเลยถ้าคุณเซ็นด้วยไฟล์ PFX เท่านั้น จึงเป็นเหตุผลที่มักจับนักพัฒนาที่ย้ายจากการเซ็นด้วย PFX แบบเรียกครั้งเดียวมาใช้ใบรับรองที่ฝ่าย IT ติดตั้งไว้ในโปรไฟล์ผู้ใช้อยู่แล้วได้ไม่ทันตั้งตัว

การเลือกใบรับรองจาก store

HotPDF เปิดเส้นทางนี้ผ่าน HPDFSignPDFStreamWithSystemCertificate และ HPDFSignPDFFileWithSystemCertificate ซึ่งทั้งคู่ขับเคลื่อนด้วย record THPDFCertificateStoreSelector อันประกอบด้วย Location (cslCurrentUser หรือ cslLocalMachine), StoreName (ค่าเริ่มต้นคือ 'MY' ซึ่งเป็น personal store), Thumbprint แบบ SHA-1 และ flag AllowUI ค่า thumbprint จะถูก normalize ภายในให้เอง ดังนั้นเครื่องหมายขีดหรือช่องว่างที่ copy มาตรงๆ จาก Certificate Manager UI จะถูกตัดออกก่อนเปรียบเทียบ

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 ก็ปฏิบัติตามนั้นตรงตัว ถ้าใบรับรองที่ match อยู่มี private key อยู่บน smart card หรือ token ที่ต้องใส่ PIN แล้ว Windows ยังไม่ได้ cache PIN นั้นไว้ CryptAcquireCertificatePrivateKey จะล้มเหลวทันทีแทนที่จะเด้ง dialog ขึ้นมาจากสิ่งที่อาจเป็น service process ความล้มเหลวนี้ชัดเจนมาก เป็น EHPDFCMSError ที่คุณเห็นทันที แต่ก็เข้าใจผิดได้ง่ายว่าเป็น "ไม่พบใบรับรอง" ทั้งที่สาเหตุจริงคือ token กำลังรอ PIN ที่ไม่มีใครจะพิมพ์ให้

ทำไม CNG กับ CAPI ถึงไม่ตรงกันเรื่องลำดับไบต์

backend ไหนจะตอบกลับมาไม่ใช่เรื่องเดา CryptAcquireCertificatePrivateKey รายงานเรื่องนี้ตรงๆ ผ่าน out-parameter ชื่อ KeySpec และค่าตัวเดียวนี้แหละที่ตัวเซ็นของ HotPDF ใช้แยกสาขาการทำงาน คีย์จาก CNG Key Storage Provider จะคืนค่า KeySpec เป็นค่า sentinel CERT_NCRYPT_KEY_SPEC ($FFFFFFFF) ส่วนค่าอื่นใดถือเป็นคีย์ CryptoAPI CSP แบบดั้งเดิม ใบรับรองส่วนบุคคลส่วนใหญ่ที่ออกหรือ import บน Windows รุ่นปัจจุบันจะ resolve เป็น CNG แม้ว่า legacy CSP shim จะยังคงอยู่เพื่อความเข้ากันได้ก็ตาม นี่เป็นเหตุผลที่ HotPDF ขอ flag CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG ควบคู่กับ CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG ก่อนจะดูว่าค่าไหนถูกคืนกลับมา

สอง backend นี้ไม่เพียงเรียกฟังก์ชันคนละตัวเท่านั้น (NCryptSignHash สำหรับคีย์ CNG และ CryptSignHashA สำหรับคีย์ CSP) แต่ยังคืนลายเซ็น RSA ดิบกลับมาในลำดับไบต์ที่ตรงข้ามกันด้วย ผลลัพธ์ของ CNG นั้นตรงกับที่ PKCS#1 คาดหวังอยู่แล้ว คือ octet string แบบ big-endian ไบต์ที่มีนัยสำคัญสูงสุดมาก่อน ตรงกับสิ่งที่การแปลง I2OSP ของ RFC 8017 สร้างขึ้น และตรงกับสิ่งที่ CMS SignerInfo (RFC 5652) ต้องการในฟิลด์ signature ของมันตาม ISO 32000-1 §12.8.3 ในทางกลับกัน CryptSignHash ของ CryptoAPI คืนลายเซ็นแบบ little-endian ซึ่งเป็นพฤติกรรมที่มีบันทึกไว้ ย้อนไปถึงวิธีที่ CSP รุ่นเก่าเก็บตัวเลขขนาดใหญ่ในหน่วยความจำ ถ้าข้ามการกลับไบต์ในเส้นทาง CAPI ทุกไบต์ของลายเซ็นจะอยู่ผิดตำแหน่ง คณิตศาสตร์ของ 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;

แล้ว custom signer callback ล่ะ

ใครก็ตามที่ข้ามตัวเซ็นใบรับรองในตัวของ HotPDF ไปเองก็ต้องรับกฎเรื่องลำดับไบต์เดียวกันนี้ไปด้วย HPDFCMSSignPDFStreamWithExternalSigner รับ THPDFCMSSignDigestCallback ซึ่งเป็น closure ชนิด reference to function(const SignedAttributesSHA256: TBytes): TBytes สำหรับเซ็นผ่าน HSM, middleware ของ smart card หรืออะไรก็ตามที่ไม่ใช่ใบรับรองที่ Windows store จะยื่น key handle ให้คุณได้ ไม่ว่า backend เบื้องหลัง callback นั้นจะเป็นอะไร ไบต์ที่มันคืนกลับมาต้องอยู่ในลำดับ 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 ด้วย PKCS#1 padding และ CAPI ผ่าน CryptSignHashA ทั้งคู่มุ่งเป้าไปที่คีย์ RSA ที่เซ็น digest SHA-256 ขนาด 32 ไบต์ ไม่มีทางไหนเจรจา format ลายเซ็น ECDSA เลย ใบรับรองที่ private key เป็นแบบ EC ต้องใช้ signer ที่คุณเขียนเองผ่าน HPDFCMSSignPDFStreamWithExternalSigner โดย encode ลายเซ็น ECDSA ตามที่ CMS คาดหวัง แทนที่จะสมมติว่าเป็น RSA byte string ความยาวคงที่ ดังนั้นอย่าคาดหวังว่าตัวเซ็นใบรับรองในตัวจะทำงานถูกต้องกับ token ที่มีใบรับรอง EC

ทำไม C++Builder ถึง link CertOpenStore ไม่ผ่าน

เพราะ import library เริ่มต้นของ C++Builder ใน RAD Studio คือ import32.lib ไม่ได้ export CertOpenStore หรือฟังก์ชันข้างเคียงอีกห้าตัว ได้แก่ CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore และ CryptAcquireCertificatePrivateKey การ build ด้วย Delphi ไม่เคยเจอปัญหานี้ เพราะ dcc32/dcc64 resolve การ import แบบ static ของ external 'crypt32.dll' ตรงเข้าไปยัง import table ของ PE เลย C++Builder ต่างออกไป คอมไพเลอร์ Delphi สร้าง OMF .obj สำหรับการ build package แล้ว ilink32 เป็นตัว link มัน และ ณ จุดนั้น การประกาศ external เดียวกันก็เป็นแค่ symbol ที่ยังไม่ resolve รอ import library บน command line เท่านั้น การชี้ linker ไปที่โฟลเดอร์ psdk ของ Windows SDK ซึ่ง crypt32.lib ฉบับเต็ม export symbol ทั้งหกตัวจริง ก็ยังแก้ปัญหาไม่ได้อยู่ดี เพราะ ilink32 จะ link เฉพาะ import library ที่ระบุไว้บน command line เท่านั้น (ค่าเริ่มต้นคือ import32.lib cp32mt.lib) การเพิ่ม search path ไม่ได้ทำให้มัน pull อะไรเพิ่มจาก path นั้นเข้ามา การรัน tdump กับ import32.lib ยืนยันช่องว่างนี้ได้ตรงๆ พบ CertOpenStore เป็นศูนย์ ในขณะที่ crypt32.lib ของ SDK พบครบทั้งหกตัว

HotPDF แก้ปัญหานี้ด้วยวิธีเดียวกับที่มันจัดการการแจงรายการใบรับรองในที่อื่นๆ ของไลบรารีอยู่แล้ว แทนที่จะขอ symbol เหล่านี้จาก linker มันจะโหลดตอน runtime แทน record ภายในชื่อ THPDFCryptoProcs เก็บ handle ของ crypt32.dll, handle ของ advapi32.dll และฟิลด์ function-pointer อีกสิบเอ็ดตัว LoadCryptoProcs โหลด DLL ทั้งสองและ resolve จุดเข้าทำงานทุกตัวด้วย GetProcAddress เพียงครั้งเดียว ที่จุดเริ่มต้นของ HPDFSignPDFStreamWithSystemCertificate โดยจะยก EHPDFCMSError ทันทีถ้ามีอะไรขาดหายไป แทนที่จะล้มเหลวทีหลังด้วย access violation ลึกเข้าไปในขั้นตอนการเซ็น

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;

การโหลดเกิดขึ้นครั้งเดียวต่อการเรียกหนึ่งครั้ง แทนที่จะโหลดแบบ lazy อยู่ในแต่ละ helper เพราะ closure ที่เลือกระหว่าง CNG กับ CAPI จับ (capture) ตาราง function ที่โหลดแล้วไว้แบบ by value และต้องคงอยู่ตลอดขั้นตอนการเซ็นทั้งหมด รวมถึง callback เข้าไปยัง HPDFCMSSignPDFStreamWithExternalSigner ด้วย handle ของ DLL ทั้งสองจะถูกปล่อยใน block finally ชั้นนอกสุดเมื่อการเซ็นเสร็จสิ้นหรือเกิด exception สิ่งเหล่านี้ไม่กระทบพื้นผิว public เลย HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate และ THPDFCertificateStoreSelector ยังคง signature เดิมทุกประการ ดังนั้นการรับ fix นี้คือแค่ rebuild สำหรับผู้เรียกใช้เดิม ไม่ใช่การแก้โค้ด

สิ่งที่บทความนี้ไม่ครอบคลุม

การแก้ลำดับไบต์และแก้การ link ของ C++Builder ให้ถูกต้อง จะได้ CMS SignerInfo ที่ validator แกะได้และลายเซ็นที่ตรวจสอบทางคณิตศาสตร์ได้ แต่ไม่ได้บอกอะไรเลยว่า validator นั้นควรเชื่อใบรับรองเบื้องหลังมันหรือไม่ เพราะการสร้าง chain, การตรวจสอบการเพิกถอน (revocation) และนโยบาย timestamp เป็นเรื่องแยกต่างหากที่ซ้อนทับเข้ามาผ่าน CMS options ไม่ใช่สิ่งที่ความถูกต้องของลำดับไบต์จะให้มาฟรีๆ มีรายละเอียดด้าน housekeeping สองอย่างที่สำคัญพอๆ กับเรื่อง cryptography คือ PCCERT_CONTEXT ที่ได้จากการค้นหาใบรับรองต้องถูกปล่อยด้วย CertFreeCertificateContext ก่อนที่ store จะปิด และ key handle ของ CNG หรือ CSP ที่ได้มา เมื่อ API รายงานว่าผู้เรียกเป็นเจ้าของ ต้องถูกปล่อยผ่านฟังก์ชันของ backend ที่ตรงกันเท่านั้น ห้ามใช้ของอีกฝั่งเด็ดขาด ถ้าผลลัพธ์ svValid ที่คุณได้หลังจากทั้งหมดนี้แคบกว่าที่คาดไว้ บทความเรื่องการตรวจสอบลายเซ็นดิจิทัลของ PDF อธิบายไว้ชัดเจนว่า flag นั้นรับประกันอะไรและไม่รับประกันอะไร เพราะใบรับรองอยู่ในความดูแลของ Windows ตลอดเวลาในกรณีนี้ การเซ็นผ่าน cert-store จึงหลีกเลี่ยง attack surface ทั้งชุดไปได้เลย ไม่มีไฟล์ PKCS#12 ต้อง parse และไม่ต้องเดิน ASN.1 เอง ซึ่งเป็นปัญหาที่การเสริมความแข็งแกร่งด้าน PKCS#12 และ ASN.1 ของ HotPDF แก้ไขไว้สำหรับเส้นทางเซ็นด้วยไฟล์ PFX แทน

การเซ็นผ่าน cert-store, การเซ็นด้วย PFX และ callback แบบ external-signer เป็นสามประตูที่นำไปสู่ pipeline CMS/PKCS#7 เดียวกันในคอมโพเนนต์ PDF ของ HotPDF สำหรับ Delphi และ C++Builder และการเลือกใช้ทางที่ถูกต้องส่วนใหญ่ขึ้นอยู่กับว่าใครมีสิทธิ์ถือ private key นี้ โปรเซสของคุณเอง ไฟล์ PFX หรือ Windows เอง