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

ดึง AIA กับ CRL ของ OpenSSL สำหรับลายเซ็น PDF ใน Delphi

PDFium VCL ตอนนี้ต่อห่วงโซ่ลายเซ็น PDF ให้สมบูรณ์และเช็ก revocation ผ่านเครือข่ายได้บน backend OpenSSL: เมื่อเปิด OnlineRetrieval ConfigureSslCmsVerifier จะติดตั้ง verifier ที่ดาวน์โหลดใบรับรอง intermediate ที่ขาดจาก URL caIssuers ของ AIA และ CRL จาก CRL distribution point โดยอยู่ในงบเวลา จำนวน request และไบต์ที่ตายตัวต่อการเรียกตรวจหนึ่งครั้ง ใบรับรองที่ดาวน์โหลดมาเป็นแค่วัตถุดิบของห่วงโซ่เท่านั้น ความไว้วางใจยังมาจาก system store กับ anchor ที่คุณตั้งไว้เท่านั้น

ช่องว่างที่การแก้นี้ปิดลงโผล่ตอนที่คุณตรวจ PDF โลกจริงบนเซิร์ฟเวอร์ Linux ครั้งแรก ผู้เซ็นจำนวนมากฝังลงใน CMS แค่ใบรับรอง leaf ของตัวเอง OpenSSL จึงเอื้อมถึง root ไม่ถึง TrustStatus กลับมาเป็น invalid และ revocation ไม่เคยรัน เพราะห่วงโซ่ไม่เคยกลายเป็นสิ่งที่ไว้ใจได้ก่อน ก่อน v3.121.0 backend OpenSSL ที่เล่าไว้ในการ verify ลายเซ็น PDF ด้วย OpenSSL ใน PDFium VCL เป็นแบบออฟไลน์เด็ดขาด และ OnlineRetrieval ไม่มีผลกับมันเลย เรื่องหนึ่งควรพูดให้ชัดตั้งแต่ต้น: engine PDFium เองไม่ทำ CMS verification เลยสักนิด กฎทุกข้อด้านล่างจึงอาศัยอยู่ในชั้น PAdES ของ component กับ OpenSSL binding ของมัน ซึ่งคุณเปิดอ่านได้

backend OpenSSL ตรวจ ดึงข้อมูล และเช็กตามลำดับไหน

ความสมบูรณ์ก่อน แล้วความไว้วางใจ แล้ว revocation โดยเครือข่ายถูกแตะเฉพาะระหว่างขั้นที่ต้องใช้เท่านั้น VerifyCmsWithSsl เช็กลายเซ็น CMS กับ signed attributes (RFC 5652) โดยกดการประเมินห่วงโซ่ไว้ ถ้าพังมันคืนทันที ก่อนที่ fetch session จะเกิดขึ้นด้วยซ้ำ เอกสารที่ไบต์พังจึงไม่ยิง request ออกนอกเครื่อง ต่อเมื่อห่วงโซ่พังต่อและ OnlineRetrieval เปิดอยู่เท่านั้นที่มันไล่ตามลิงก์ AIA แล้วตรวจใหม่ CRL distribution point ถูกดึงต่อเมื่อห่วงโซ่ได้รับความไว้ใจแล้ว เพราะ CRL ที่แขวนอยู่กับเส้นทางที่ไม่น่าเชื่อถือไม่พิสูจน์อะไรเลย คำตัดสินทั้งสามแยกกันตลอดสาย: ลายเซ็นที่ถูกต้องแต่ห่วงโซ่ไม่สมบูรณ์ก็ยังถูกรายงานเป็นลายเซ็นที่ถูกต้อง

ลำดับการทำงานของ VerifyCmsWithSsl ใน backend OpenSSL ของ PDFium Component: การเช็กลายเซ็น CMS รันโดยกดการประเมินห่วงโซ่ไว้ ไบต์ที่พังจึงไม่แตะเครือข่ายเลย RetrieveIntermediates ไล่ตาม URL caIssuers ของ AIA ต่อเมื่อห่วงโซ่พังและเปิด OnlineRetrieval และ RetrieveCrls ดึง CRL จาก distribution point เข้า store แยกต่างหากเมื่อห่วงโซ่ได้รับความไว้ใจแล้ว
ความสมบูรณ์ แล้วความไว้วางใจ แล้ว revocation: เครือข่ายถูกแตะเฉพาะระหว่างขั้นที่ต้องใช้ และ CRL ที่แขวนกับเส้นทางที่ไม่น่าเชื่อถือไม่พิสูจน์อะไรเลย
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER แหล่งความไว้วางใจเพิ่มเติมทางเดียว
  ConfigureSslCmsVerifier;

  Probe := TPdfCmsVerifyOptions.Default;
  Probe.OnlineRetrieval := True;
  Probe.CheckRevocation := True;
  Diags := SslVerifyOptionsDiagnostics(Probe);
  if psvdOnlineRetrievalIgnored in Diags then
    Log('no HTTP transport or CMS_add1_cert: validation stays offline');

  Trust := TPadesTrustValidationOptions.Default;  // default เป็น ptnpOffline
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // ต่อการเรียกตรวจหนึ่งครั้ง

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'signed-contract.pdf';
    Pdf.Active := True;
    Verdict := Pdf.ValidatePadesTrust(Trust);
    for I := 0 to High(Verdict.Signatures) do
      Log(Format('#%d trust=%d revocation=%d', [I,
        Ord(Verdict.Signatures[I].CertificateTrustStatus),
        Ord(Verdict.Signatures[I].RevocationStatus)]));
  finally
    Pdf.Free;
  end;
end;

ทำไมใบรับรองที่ดาวน์โหลดมาถึงไม่เคยถูกเพิ่มเข้า trust store

เพราะ URL พวกนั้นมาจากใบรับรองที่กำลังถูกตรวจ และผู้เซ็นเป็นคนเลือกมันเอง entry caIssuers ของ authorityInfoAccess (RFC 5280 §4.2.2.1) เป็นแค่ใบ้ว่า issuer อยู่ที่ไหน ไม่มีมากไปกว่านั้น ถ้าสิ่งที่ตอบ URL นั้นได้เข้าไปอยู่ใน trust-anchor store ใครก็เซ็นด้วย key ที่ทำเอง ชี้ AIA ไปที่เซิร์ฟเวอร์ของตัวเอง แล้วรับคำตัดสินเขียวกลับไปได้ RetrieveIntermediates จึงส่งใบรับรองที่ parse แล้วแต่ละใบเข้า CMS_add1_cert ซึ่งวางมันลงชุด untrusted ของโครงสร้าง CMS ตัวนี้ตัวเดียว และ OpenSSL ยังต้องสร้างเส้นทางจากมันไปถึง anchor ที่คุณตั้งไว้หรือที่ system store ถืออยู่ มีเหตุผลเงียบ ๆ อีกข้อด้วย: argument ใบรับรองของ CMS_verify ไม่ใช่ตัวแทนสำเร็จรูปของใบรับรองที่ฝังใน CMS การเพิ่มลงตัว CMS เองจึงเป็นเส้นทางที่เชื่อถือได้

loop การดึงข้อมูลถูกทำให้แคบลงอย่างตั้งใจ RetrieveIntermediates รันมากที่สุด 4 รอบ แต่ละรอบเก็บ URL caIssuers จากทุกใบรับรองที่อยู่ใน CMS ขณะนั้น และหยุดทันทีที่รอบใดไม่เพิ่มอะไรเลยหรืองบเวลาหมด response ต้อง decode ด้วย d2i_X509 เป็นใบรับรอง DER ใบเดียวที่กิน body ทั้งก้อน ไบต์ต่อท้ายถูกปฏิเสธ และ bundle PKCS#7 แบบมีแต่ใบรับรองที่เสิร์ฟจาก URL .p7c ถูกข้ามแทนที่จะแกะ access method OCSP ใน extension AIA เดียวกันถูกเมิน เพราะ backend ตัวนี้ไม่พูดภาษา OCSP ฝั่ง revocation RetrieveCrls อ่านแค่ URI fullName ของ DistributionPoint แต่ละตัว (RFC 5280 §4.2.1.13) จากใบรับรองใน CMS กับ anchor ที่ตั้งไว้ และ CRL ที่ดาวน์โหลดมาไปลง X509_STORE ตัวที่สองที่เป็นอิสระต่อกัน พร้อมการเช็ก CRL ทั้งห่วงโซ่ CRL ที่หายไปหรือเก่าไปจึงเปลี่ยน RevocationStatus โดยไม่แตะ TrustStatus เลย

// ย่อจาก VerifyCmsWithSsl (FPdfCryptoSsl.pas) ตัดส่วนตั้ง BIO ออก
// การเรียก _CMS_verify ทุกครั้งได้ content BIO ใหม่เอี่ยม
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // ลายเซ็นพัง: ไม่แตะเครือข่ายเลย
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
  FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);

Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
   (FetchSession <> nil) then
begin
  RetrieveIntermediates(Cms, FetchSession);  // CMS_add1_cert, ลง untrusted เท่านั้น
  // ตรวจห่วงโซ่ซ้ำกับ anchor store ตัวเดิม
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// store ที่สอง แยกต่างหาก: CRL ที่ตั้งไว้บวกที่ดึงมาได้
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

การเรียกตรวจหนึ่งครั้งแพงสุดเท่าไร

เพดานที่ตายตัว บังคับโดย TPdfCryptoFetchSession ตัวเดียวที่ขั้น AIA กับ CRL ของการเรียกตรวจหนึ่งครั้งใช้ร่วมกัน ขีดจำกัดเป็นค่าคงที่ใน FPdfCryptoHttp ไม่ใช่ข้อเสนอแนะ:

  • เวลา: UrlRetrievalTimeoutMs ซึ่ง default 15000 ทั้งใน TPdfCmsVerifyOptions.Default และ TPadesTrustValidationOptions.Default session ที่สร้างด้วย 0 จะตกไปใช้ 30000 และนาฬิกาเดินเมื่อลายเซ็นผ่านแล้ว ครอบคลุม request ทุกตัวหลังจากนั้น
  • จำนวน request: มากที่สุด 8 ต่อ session นับก่อนลองใช้ transport เพื่อให้ host ตายก็ยังกินโควตาไปหนึ่ง
  • ขนาดข้อมูล: 1 MiB ต่อ response และ 4 MiB รวม โดย URL ที่ยาวเกิน 2048 ตัวอักษรถูกปฏิเสธก่อนเชื่อมต่ออะไรเลย
เพดานตายตัวของ TPdfCryptoFetchSession หนึ่งตัวที่ขั้น AIA กับ CRL ของการเรียกตรวจใน PDFium Component ใช้ร่วมกัน: UrlRetrievalTimeoutMs default 15000 ms ตกไปใช้ 30000 เมื่อเป็นศูนย์, มากที่สุด 8 request ต่อ session, 1 MiB ต่อ response และ 4 MiB รวมโดย response ที่พังก็ยังถูกนับ และ URL ที่ยาวเกิน 2048 ตัวอักษรถูกปฏิเสธ
ขีดจำกัดเป็นค่าคงที่ ไม่ใช่ข้อเสนอแนะ: ไบต์จาก response ที่พังก็ยังระบายงบ และเพราะลายเซ็นกับ timestamp แต่ละตัวถูกตรวจแยกกัน กรณีแย่ที่สุดจึงโตตามจำนวนลายเซ็น

การทำบัญชีเข้มกว่าที่ดูแรก ๆ ไบต์ที่รับจาก response ที่พังก็ยังถูกหักเข้ายอดรวม เซิร์ฟเวอร์ที่ตอบ 404 พร้อมหน้าใหญ่ ๆ จึงระบายงบฟรี ๆ ไม่ได้ การอ่านที่ข้ามขีดจำกัดต่อ response จะยกเลิกการดาวน์โหลด แทนที่จะส่ง body ที่ถูกตัดไปให้ parser ASN.1 และ HTTP 200 ที่ body ว่างเปล่าถูกปฏิเสธทันที เพราะเส้นทาง AIA ไม่งั้นจะไปชี้ Data[0] ของ array ว่าง ผ่านมีแต่ URL http:// กับ https:// ธรรมดา ไม่มี redirect, cookie, credential หรือการค้น proxy อัตโนมัติ ขณะที่ HTTPS คงการเช็กใบรับรองกับ hostname ตามปกติ การ dedup URL ถูกจำกัดขอบเขตไว้ต่อการเรียกเดียวอย่างตั้งใจ: การตรวจครั้งถัดไปต้องมองเห็น CRL ที่เพิ่งเผยแพร่ได้ งบยังเป็นต่อการเรียก ไม่ใช่ต่อเอกสาร และ ValidatePadesTrust ตรวจลายเซ็นกับ timestamp token แต่ละตัวแยกกัน กรณีแย่ที่สุดจึงโตตามจำนวนลายเซ็น

ทำไม request WinHTTP ที่หมดเวลาถึงยังเขียนลงหน่วยความจำของคุณได้

เพราะการคืนค่าเมื่อหมดเวลาไม่ได้ยกเลิก callback ที่กำลังบินอยู่ transport ฝั่ง Windows ขับ WinHTTP แบบ asynchronous แล้วรอ event ด้วยเวลาที่เหลือของ session เมื่อการรอยอมแพ้ request ยังสามารถจบการอ่านและส่งสัญญาณตามมาได้ ถ้าชี้การอ่านแบบ asynchronous ไปที่ buffer บน stack การจบล่าช้านั้นจะเขียนลง frame ที่ตอนนั้นเป็นของฟังก์ชันอื่นไปแล้ว การแก้คือเรื่องความเป็นเจ้าของ ไม่ใช่เรื่องเวลา: event กับ buffer อ่าน 16 KB อาศัยอยู่ใน record บน heap ที่มี reference สองตัว หนึ่งถือโดยผู้เรียก อีกหนึ่งปล่อยโดย callback HANDLE_CLOSING ตัวสุดท้าย ฝั่งไหนจบทีหลังฝั่งนั้นคืนหน่วยความจำ

ทำไม request WinHTTP ที่หมดเวลาถึงยังเขียนลงหน่วยความจำได้: การคืนค่าเมื่อหมดเวลาทิ้ง callback ให้บินต่อ PDFium Component จึงชี้การอ่านแบบ asynchronous ไปที่ record THttpState บน heap ซึ่ง buffer 16 KB กับ reference สองตัว หนึ่งถือโดยผู้เรียก อีกหนึ่งปล่อยโดย callback HANDLE_CLOSING ตัวสุดท้าย ถูกคืนเมื่อฝั่งสุดท้ายจบเท่านั้น
การจบล่าช้าอาจจบการอ่านหลังจากที่การรอของคุณยอมแพ้ไปแล้ว ความเป็นเจ้าของแบบ heap ที่มี reference สองตัวทำให้การเขียนนั้นลงในหน่วยความจำที่ยังมีชีวิต
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // ผู้เรียก + callback HANDLE_CLOSING ตัวสุดท้าย
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // การอ่านแบบ async ลงที่นี่ ไม่ลงบน stack เด็ดขาด
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// ใน status callback: HANDLE_CLOSING คือการแจ้งเตือนสุดท้ายที่ WinHTTP
// ส่งให้กับ request มันจึงปล่อย reference ตัวที่สอง
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

libcurl ต้องมีอะไรบน FPC Unix

resolver แบบ asynchronous กับบิลด์ที่ thread-safe ไม่งั้นการดึงข้อมูลออนไลน์ปิดค้างไป บน FPC Unix transport เดินผ่าน libcurl ซึ่งเป็น dependency ตัวเดียวกับที่อยู่หลังbackend timestamp แบบ libcurl สำหรับเป้าหมายที่ไม่ใช่ Windows และ binding ปฏิเสธ library ใด ๆ ที่ feature mask ขาด CURL_VERSION_ASYNCHDNS หรือ CURL_VERSION_THREADSAFE เหตุผลคือ CURLOPT_NOSIGNAL ซึ่ง library ที่อาศัยใน process คนอื่นต้องตั้ง ถ้าจับคู่กับ resolver แบบ synchronous แปลว่า DNS lookup มีสิทธิ์อายุยาวกว่า timeout ได้เฉย ๆ กับดักที่สองคือตอนปิด: curl_global_cleanup ไม่รอ thread DNS แบบ asynchronous module จึงถูก map ค้างไว้จน process จบ แทนที่จะปล่อยให้ thread เบื้องหลังวิ่งเข้าโค้ดที่ถูก unload ไปแล้ว เมื่อข้อใดข้อหนึ่งไม่ผ่าน SslCapabilities.OnlineRetrieval เป็น False และ SslVerifyOptionsDiagnostics รายงาน psvdOnlineRetrievalIgnored แทนที่จะแสร้งว่าเคยปรึกษาเครือข่าย

ผลลัพธ์การันตีและไม่การันตีอะไร

RevocationStatus ที่ valid จาก backend นี้หมายความว่า CRL ปัจจุบันที่ครอบคลุมทั้งห่วงโซ่ถูกพบ ไม่ว่าจากที่ตั้งไว้หรือที่ดาวน์โหลดมา และไม่มีใบใดระบุใบรับรองในห่วงโซ่เลย ไม่มีมากไปกว่านั้น ไม่มี OCSP CA ที่เผยแพร่ revocation ผ่าน OCSP เท่านั้นจึงทิ้งผลไว้เป็น unsupported และความล้มเหลวของเครือข่ายหน้าตาเหมือน CA ที่ไม่เผยแพร่อะไรเลยสนิท อย่าลืมด้วยว่า psvdNoCrlsConfigured พูดถึงแค่ CRL ที่คุณตั้งไว้ เมื่อมีการดึงข้อมูลออนไลน์มันจึงเป็นแค่ใบ้ ไม่ใช่การทำนายความล้มเหลว เมื่อ audit trail ต้องทำซ้ำได้โดยไม่ต้องพึ่งเครือข่าย ให้ปล่อย NetworkPolicy ที่ค่า default ptnpOffline จะไม่มี fetch session ถูกสร้าง และ backend ไม่เคยเปิดการเชื่อมต่อ ซึ่งตรงกับข้อสัญญาออฟไลน์ฝั่ง CryptoAPI ที่เล่าไว้ในการเช็ก revocation ลายเซ็น PDF แบบออฟไลน์บน Windows

โค้ดการดึงข้อมูล งบประมาณ และ binding ของ transport ship มาเป็นซอร์สกับPDFium Delphi component คุณจึงยืนยันได้เป๊ะว่าการตรวจหนึ่งครั้งอาจติดต่อ URL ใดและดาวน์โหลดได้เท่าไร ก่อนจะเปิด ptnpOnline บนเซิร์ฟเวอร์ที่รับมือเอกสารที่ไม่น่าเชื่อถือ