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

ตรวจ revocation ลายเซ็น PDF แบบออฟไลน์บน Windows ใน Delphi

PDFium VCL ตรวจ revocation ของลายเซ็น PDF บน Windows แบบออฟไลน์ด้วยการเติม CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ลงรอบตรวจ revocation ของ CertGetCertificateChain เพราะ flag cache-only ที่ใช้กับการ build chain ไม่ครอบคลุมการดึง CRL หรือ OCSP เลย ตั้งแต่ v3.119.1 การเรียก ValidatePadesTrust แบบออฟไลน์ยังไม่แตะเครือข่าย และผลลัพธ์ที่สะอาดต้องมีหลักฐาน revocation ต่อ certificate จริง ๆ ที่เหลือของโพสต์นี้คือเรื่องทำไมครึ่งทั้งสองของประโยคนั้นถึงต้องถูกแก้

การตั้งค่าที่เผยปัญหานี้ธรรมดามาก service ตรวจสอบรันบน Windows host ที่ล็อกแน่น TPadesTrustValidationOptions.NetworkPolicy เป็น ptnpOffline (ซึ่งก็คือค่า default ด้วย) และผู้ดูแลคาดหวังว่าทุกคำตอบมาจาก cache certificate ในเครื่อง แล้วบางคนก็สังเกตเห็น request ออกนอกไปยัง CA distribution point ใน log ของ firewall หรือ batch job ที่ติดค้างเต็มเวลา UrlRetrievalTimeoutMs 15000 ms ทุกลายเซ็น ไม่มีอะไรในโค้ดขอเครือข่ายเลย Windows ก็ไปเอง

ทำไมการ build chain แบบออฟไลน์ยังไปดึง CRL บน Windows

เพราะ CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL จำกัดแค่การดึง URL ที่การ build chain ทำ: การ fetch AIA issuer, การอัปเดต root กับ CTL เอกสารของ Microsoft สำหรับ CertGetCertificateChain บอกชัดว่า flag นี้ไม่ใช้กับการตรวจ revocation revocation มีสวิตช์ของตัวเองคือ CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000) ถ้าไม่มีมัน provider ฝั่ง revocation ก็ยังดาวน์โหลด CRL หรือส่ง OCSP request ได้อย่างอิสระ แม้การเรียกรอบนอกจะดูออฟไลน์ก็ตาม PDFium VCL ตอนนี้ OR flag นี้เข้ารอบตรวจ revocation ทุกครั้งที่ OnlineRetrieval เป็น False วางทับบน chain flags, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT กับ CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT เรื่องนี้สำคัญเกินความล่าหน่วยเดียว: OCSP request หนึ่งคำถามบอก responder ว่าคุณกำลังมอง certificate ตัวไหน ซึ่งเป็นสิ่งที่ validator แบบ air-gapped ควรเลี่ยงพอดี

สวิตช์อิสระสองตัวคุมการตรวจลายเซ็น PDF แบบออฟไลน์บน Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL จำกัดแค่การ build chain เช่นการ fetch AIA issuer กับ root ส่วน revocation ต้องใช้ CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY เพราะถ้าไม่มี provider ยังดาวน์โหลด CRL และส่ง OCSP request ที่เผยว่ากำลังตรวจ certificate ตัวไหน
PDFium VCL OR flag cache-only ฝั่ง revocation เข้ารอบตรวจ revocation ทุกครั้งที่ OnlineRetrieval เป็น False การตรวจ trust แบบ ptnpOffline จึงไม่แตะเครือข่ายในทั้งสองรอบ
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // ค่า default เป็น False
  Options.CheckTimeStamps := True;
  // ออฟไลน์ตอนนี้หมายถึงออฟไลน์ฝั่ง revocation ด้วย: ใช้ CRL กับ OCSP
  // จาก cache เท่านั้น ไม่มี checkpoint pcvstOnlineRetrieval ถูกยกขึ้น
  Report := Pdf.ValidatePadesTrust(Options);
end;

build chain สองรอบ field ข้อผิดพลาดสองช่อง

backend ฝั่ง Windows build chain สองรอบ และแต่ละรอบตอนนี้มี slot ข้อผิดพลาดของตัวเอง การเรียก CertGetCertificateChain รอบแรกไม่ใส่ flag revocation และป้อน CertVerifyCertificateChainPolicy ด้วย base policy ซึ่งผลิต TrustStatus กับ TrustError รอบที่สองเติม flag revocation ก่อน v3.119.1 ความล้มเหลวของรอบที่สองเขียน GetLastError ลง TrustError chain ที่เพิ่งถูกตรวจว่าเชื่อถือได้จึงกลับมาดูเหมือนไม่น่าเชื่อถือได้ เพียงเพราะ provider ฝั่ง revocation สะดุด การแก้อ่าน GetLastError ทันทีแล้วเก็บลง TPdfCmsVerifyResult.RevocationError ปล่อยคำตัดสินรอบแรกให้เงียบ ๆ และค่า True ที่คืนจากรอบที่สองก็ไม่ได้ถูกถือเป็นความสำเร็จเช่นกัน มันแปลว่าแค่ Windows ส่ง chain context ที่คุ้มค่าแก่การตรวจกลับมา

mask trust error เป็นศูนย์พิสูจน์อะไรได้จริง

ด้วยตัวเองแล้ว ไม่พิสูจน์อะไรเลย TrustStatus.dwErrorStatus รวมเป็นศูนย์หลังรอบตรวจ revocation แปลว่าแค่ไม่มี bit error ถูกยกขึ้น และ chain ที่ไม่มี element ใดพกข้อมูล revocation เลยก็ผลิตผลแบบนี้ได้พอดี โค้ดก่อนหน้า map "ไม่มี bit revoked, ไม่มี bit unknown, ไม่มี bit offline" ตรงไปที่ valid ซึ่งเป็นวิธีคลาสสิกที่ validator รายงาน certificate ที่ยังไม่ได้เช็กเป็นตัวสะอาด รูทีนใหม่ ReadWinRevocationEvidence เดินทุก simple chain ทุก element ปฏิเสธโครงสร้างที่ cbSize เล็กเกินกว่าจะอ่านได้อย่างปลอดภัย และรายงานความสำเร็จเฉพาะเมื่อมี element ที่ไม่ใช่ root อย่างน้อยหนึ่งตัวและ element พวกนั้นทุกตัวพก CERT_REVOCATION_INFO ที่ dwRevocationResult เป็นศูนย์

การเดินหาหลักฐานที่ ReadWinRevocationEvidence ทำกับ element ของ chain ฝั่ง Windows แต่ละตัวใน Delphi: pRevocationInfo ต้องมีอยู่, cbSize ต้องใหญ่พอจะอ่านได้, dwRevocationResult ต้องเป็นศูนย์ และ element ปลายทางถูกยกเว้นเฉพาะเมื่อถูก mark ว่า self-signed หรือ CA-trusted mask trust error เป็นศูนย์จึงไม่มีทางซ่อน certificate ที่ยังไม่ได้ตรวจอีกต่อไป
คำตัดสินที่สะอาดต้องมี element ที่ไม่ใช่ root อย่างน้อยหนึ่งตัวและคำตอบจากทุก element ที่จำเป็น โดย RevocationError เก็บ DWORD ดิบจาก provider อย่าง CRYPT_E_REVOKED
// ย่อจากการเดินหาหลักฐาน: element นับเฉพาะเมื่อมี
// provider ฝั่ง revocation ตอบให้จริง ๆ
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); // chain ที่มีแต่ root พิสูจน์ไม่ได้อะไรเลย

ผลจาก provider ถูกเก็บแบบดิบ RevocationError ถือ DWORD dwRevocationResult ตรงตามที่ provider คืนมา โดยเลือก error จาก element ที่ถูก revoke ก่อนถ้ามี (CRYPT_E_REVOKED คือ $80092010) และ bitmask ฝั่ง trust ไม่เคยถูกแต่งให้เป็น error code เนทีฟ การ map ไป TPdfCmsRevocationReason หยาบอย่างตั้งใจ: pcrrCertificateRevoked คู่กับ pcvsInvalid สำหรับการ revoke อย่างชัดเจน, pcrrChainUntrusted เมื่อ chain ล้มเหลวด้วยเหตุผลที่ไม่เกี่ยวกับ revocation และ pcrrUnknown สำหรับที่เหลือ อาจมีทางที่ Windows ลองใช้ OCSP แทน CRL ผลแบบ offline หรือ unknown จึงไม่ถูกแปลเป็น pcrrCrlExpired backend ตรวจ CMS ของ OpenSSL แยกรายละเอียดเฉพาะ CRL พวกนี้ได้ เพราะมันประเมินแต่ CRL ที่คุณยื่นให้เท่านั้น ส่วนbackend SecTrust ของ macOS ปล่อย field ไว้ที่ pcrrNone กับศูนย์ ซึ่งแปลว่า "ไม่มีคำวินิจฉัยละเอียด" ไม่ใช่ "ผ่าน"

การยกเว้น root หยุดตรงไหน

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT ข้าม anchor อย่างถูกต้อง เพราะไม่มีใครเผยแพร่ CRL ที่ revoke root ต่อตัวเอง กับดักคือการตัดสินว่า element ไหนคือ root PDFium VCL ยกเว้น element ตัวสุดท้ายของ simple chain เฉพาะเมื่อ dwInfoStatus ของมัน mark ว่า self-signed ($00000008) หรือ CA-trusted อย่างชัดเจน ($00004000) เครื่องออฟไลน์มัก fetch issuer ที่ขาดหายไม่ได้ chain จึงจบที่ intermediate ถ้าถือว่า element สุดท้ายของ chain บางส่วนแบบนี้เป็น root จะเป็นการทิ้ง certificate ตัวเดียวที่สถานะ revocation ของมันมีโอกาสไม่อยู่ใน cache มากที่สุดไปโดยเงียบ ๆ element ตัวนั้นอยู่ในชุดที่จำเป็นต่อ ไม่มีคำตอบจาก provider และผลลัพธ์ก็คงเป็น pcvsIndeterminate

ผล revocation ของ signature กับ timestamp ถูกแยกกันอย่างไร

แยกเป็น field ที่ไม่มีวันเขียนทับกันเอง ตัวตรวจ PAdES verify CMS แบบ detached ของลายเซ็นเอกสารกับ CMS แบบ attached ของ token timestamp RFC 3161 ในสองการเรียกอิสระจากกัน และ v3.119.0 ให้แต่ละฝั่งมีคำวินิจฉัยของตัวเองบน TPadesSignatureValidation: RevocationReason กับ NativeRevocationError สำหรับผู้ลงลายเซ็น, TimeStampRevocationReason กับ NativeTimeStampRevocationError สำหรับ TSA certificate ของ TSA ที่ถูก revoke จึงแอบอ้างตัวเป็นผู้ลงลายเซ็นที่ถูก revoke ไม่ได้ และความล้มเหลวฝั่ง timestamp ก็ไม่ลบผล integrity ที่จัดตั้งไว้แล้ว เมื่อ CheckRevocation เป็น False หรือการตรวจไม่เคยไปถึงขั้นนั้น field จะคงอยู่ที่ pcrrNone กับ 0 จงอ่านมันคู่กับ RevocationStatus กับ TimeStampRevocationStatus เสมอ

revocation ฝั่ง signature กับ timestamp แยกกันใน PDFium VCL: CMS แบบ detached ของลายเซ็นเอกสารเติม RevocationReason กับ NativeRevocationError, CMS แบบ attached ของ token RFC 3161 เติม TimeStampRevocationReason กับ NativeTimeStampRevocationError และขั้นที่ไม่ได้ตรวจปล่อย pcrrNone กับศูนย์ไว้ข้าง field สถานะของมัน
certificate ของ TSA ที่ถูก revoke จึงแอบอ้างตัวเป็นผู้ลงลายเซ็นที่ถูก revoke ไม่ได้ และความล้มเหลวฝั่ง timestamp ไม่มีวันลบผล integrity ที่การ verify ลายเซ็นจัดตั้งไว้แล้ว
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;

รายงานหลักฐานทำตามกฎเดียวกัน export CSV เติม revocationReason, nativeRevocationError และคอลัมน์ timestamp ถึง nativeTimeStampRevocationError ต่อท้ายลำดับคอลัมน์เดิม parser รุ่นเก่าจึงยังทำงานต่อได้ และ export JSON เติม field ที่สอดคล้องกันโดยไม่เปลี่ยนความหมายของของเดิม ถ้าการตรวจแบบออฟไลน์ยังกลับมาเป็น indeterminate ต่อเนื่อง ทางแก้ที่อยู่ยังคือฝั่งต้นน้ำ: เก็บวัสดุตรวจสอบไว้ตอนลงลายเซ็น ตามที่อธิบายในลายเซ็น PDF ระยะยาว พร้อม timestamp RFC 3161 และ DSS แทนที่จะหวังว่าเครื่องที่ตรวจจะมี cache อุ่น ๆ รออยู่

เมทริกซ์เทสต์พิสูจน์ได้และไม่ได้อะไร

เมทริกซ์การตรวจฝั่ง Windows ผ่านสถานการณ์ chain-API แบบควบคุม 30 เคสและ CMS smoke ออฟไลน์จริงหนึ่งเคสบนเป้าหมาย Delphi กับ FPC ทั้ง Win32 และ Win64 smoke จริงตรวจลายเซ็นที่ถูกต้องใต้ private CA ที่ไม่น่าเชื่อถือ ส่วนผลลัพธ์แบบสะอาดกับแบบถูก revoke อย่างชัดเจนมาจากการตอบ CertGetCertificateChain แบบ stub แทนที่จะมาจาก trust anchor ที่ติดตั้งหรือการดึงจริง เป็นขอบเขตตรงไปตรงมาที่ควรพูดให้ชัด: การจัดการ flag, การแยก error และการเดินหาหลักฐานถูกตอกตายแล้ว แต่ว่า cache revocation ของเครื่องใดเครื่องหนึ่งมีอะไรในวันหนึ่งนั้นยังเป็นเรื่องของ Windows อยู่ดี และ cache ที่ว่างเปล่าตอนนี้ผลิต "unknown" อย่างถูกต้อง แทน request เข้าเครือข่ายหรือ "valid" ปลอม

การจัดการ revocation แบบออฟไลน์, คำวินิจฉัยราย field และ export หลักฐานเป็นส่วนหนึ่งของ API ตรวจลายเซ็น PDF ใน PDFium VCL for Delphi และ C++Builder ร่วมกับ backend ฝั่ง OpenSSL และ macOS สำหรับการ deploy ข้ามแพลตฟอร์ม