PDFium VCL ถือว่าการตรวจสอบ CMS เป็น backend ที่เปลี่ยนได้ซึ่งซ่อนอยู่หลังอินเทอร์เฟซ IPdfCmsVerifier ตัว validator ของ PAdES จึงวิ่งได้บน Windows ผ่าน CryptoAPI, บน macOS ผ่าน Keychain และที่ใดก็ตามที่มี OpenSSL ผ่าน ConfigureSslCmsVerifier อินเทอร์เฟซตัวนี้เล็กนิดเดียว แต่พฤติกรรม OpenSSL สามข้อที่ซ่อนอยู่ใต้มันจะให้คำตอบที่ผิดแบบมั่นใจเต็มเปี่ยม ถ้าคุณอิมพลีเมนต์มันแบบซื่อ ๆ
เหตุผลของเรื่องนี้ชัดทันทีที่แอป Delphi ออกจาก Windows ไป การตรวจความถูกต้องของลายเซ็นเป็นหนึ่งในไม่กี่พื้นที่ที่ crypto stack ของแพลตฟอร์มไม่ใช่แค่รายละเอียดอิมพลีเมนเทชัน: มันตัดสินว่าใบรับรองใดเชื่อถือได้ มีอัลกอริทึมอะไรบ้าง และการเพิกถอนหมายความว่าอย่างไร hard-code ตัวใดตัวหนึ่งไว้โค้ดก็พอร์ตไม่ได้ แต่ถ้า abstraction มันแย่ ๆ ทุกแพลตฟอร์มจะรายงานคำตอบรูปทรงไม่เหมือนกันจนผู้เรียกเทียบกันไม่ได้เลย
สิ่งที่ abstraction ต้องแบกจริง ๆ
รูปทรงการตรวจสอบสองแบบ กับคำตัดสินอิสระสามตัว ลายเซ็น PDF เป็นแบบ detached: เนื้อหาที่ถูกลงนามคือช่วงไบต์สองช่วงที่อยู่สองข้างของรู /Contents VerifyDetached จึงรับสองเซกเมนต์ ไม่ใช่บัฟเฟอร์เดียว ส่วน token ประทับเวลาเป็นแบบ attached พกเนื้อหาของตัวเองมาด้วย VerifyAttached จึงรับแค่ DER
ผลลัพธ์ถูกแยกเป็นสามสถานะ เพราะแต่ละตัวตอบคำถามต่างกันและเห็นต่างกันได้ SignatureStatus บอกว่าไบต์ถูกลงนามด้วยคีย์ในใบรับรองผู้ลงนามหรือไม่ TrustStatus บอกว่าใบรับรองนั้นไล่ห่วงโซ่ไปเชื่อมกับสิ่งที่คุณเชื่อถือหรือเปล่า RevocationStatus บอกว่าใบรับรองยังใช้ได้อยู่ในช่วงเวลาที่เกี่ยวข้องหรือไม่ เอกสารที่มีลายเซ็นสมบูรณ์แบบทางคณิตศาสตร์แต่ลงด้วยใบรับรองที่คุณไม่เคยได้ยินชื่อ คือ valid, untrusted และ unknown พร้อมกัน และการยุบทั้งหมดลงเป็น boolean ตัวเดียวคือหนทางที่ validator ต่างหน้ามักจบด้วยการโกหกผู้ใช้
uses
FPdfCrypto, FPdfCryptoSsl;
var
Options: TPdfCmsVerifyOptions;
begin
if not SslAvailable then
raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER, ว่างก็ได้
ConfigureSslCrls(LoadFreshCrls); // DER, ว่างก็ได้
ConfigureSslCmsVerifier; // ติดตั้ง backend ตัวนี้
Writeln('backend : ', PadesCmsVerificationBackendName);
Writeln('library : ', SslLibraryPath, ' ', SslLibraryVersion);
Writeln('ABI : ', SslAbiLayout); // ulong=<n> long=<n>
Options := TPdfCmsVerifyOptions.Default;
Options.CheckRevocation := True;
Options.CollectChainCertificates := True;
end;
SslAbiLayout ดูเหมือนของแปลกไขวเขวแต่ไม่ใช่ รหัส error ของ OpenSSL ทุกตัวและแฟล็กของ store ทุกตัวข้ามพรมแดนมาเป็น C unsigned long ซึ่งกว้างสี่ไบต์บน Windows และแปดไบต์บน Linux กับ macOS ประกาศมันเป็นไทป์ 32 บิตคงที่ โค้ดจะทำงานบน Windows แล้วไปอ่านค่ามาครึ่งเดียวอย่างเงียบ ๆ บน LP64 การรายงานความกว้างที่สมมติไว้เป็นสตริงที่ assert ได้ในเทสต์ เปลี่ยนความเพี้ยนแบบ platform ABI ทั้งตระกูลให้เหลือการเช็กบรรทัดเดียว ใครที่เคยลุยปัญหาเดียวกันกับ CK_ULONG ใน binding แบบ PKCS#11 มาก่อนจะจำตัวมันได้ทันที เรื่องนั้นอยู่ในบทความ PKCS#11 struct packing และความกว้าง CK_ULONG
ทำไมรอบตรวจสอบที่สองถึงเห็นเนื้อหาว่างเปล่า?
เพราะ CMS_verify อ่าน content BIO แบบ detached ไปจนถึงท้ายไฟล์ และ BIO ที่ถูกอ่านไปแล้วไม่มีใครมา rewind ให้คุณ การตรวจสอบเป็นสองรอบเป็นดีไซน์ที่สมเหตุสมผล รอบแรกเช็กลายเซ็นเชิงเข้ารหัสลับอย่างเดียวโดยกดการประเมินห่วงโซ่ทิ้ง รอบสองค่อยประเมินเต็มรูปแบบ และมันจะล้มในแบบที่หลอกคนเก่งผิดปกติ ถ้าสองรอบใช้ BIO ตัวเดียวกัน
รอบที่สองจะได้เนื้อหามาศูนย์ไบต์ ในโหมด detached นั่นไม่ใช่ error เพราะ content buffer ว่างเปล่าเป็นอินพุตที่ถูกต้องตามกฎ แฮชก็เลยไม่ตรงง่าย ๆ แล้วความล้มเหลวจะปรากฏตัวเป็นความล้มเหลวของการสร้างห่วงโซ่ ไม่ใช่ความล้มเหลวของเนื้อหา ซึ่งจะพาคุณไปเดาเรื่อง certificate กับ trust store ทั้งที่ปัญหาจริงคือตำแหน่งสตรีม สร้าง memory BIO ใหม่ด้วย BIO_new_mem_buf ทุกรอบไปเลย แลกด้วยการจัดสรรหนึ่งครั้ง และความเป็นไปได้นี้ก็หายขาด
แฟล็ก no-verify กดอะไรได้และกดอะไรไม่ได้
CMS_NO_SIGNER_CERT_VERIFY กดการประเมินห่วงโซ่ ไม่ใช่กดการค้นหา certificate ของผู้ลงนาม ภายใน OpenSSL จะ resolve กับแนบ certificate ผู้ลงนามเสร็จก่อนที่มันจะไปดูแฟล็ก หลังรอบแรกที่แบกแฟล็กนี้มา ผู้ลงนามจึงพร้อมใช้อยู่แล้ว และ algorithm identifier ของมันอ่านได้ทันที ไม่จำเป็นต้องวิ่งรอบตรวจสอบเต็มรอบที่สองแค่เพื่อเอา certificate ของผู้ลงนามมาคืน ซึ่งเป็นสิ่งที่ชื่อแฟล็กมันหลอกให้คุณคิดแบบนั้น
มีกฎความเป็นเจ้าของหนึ่งข้อที่มาพร้อมเรื่องนี้ reference ของผู้ลงนามเป็นของโครงสร้าง CMS และห้ามปล่อยอิสระจากกัน มันใช้ได้นานเท่ากับที่โครงสร้างยังอยู่ การไปปล่อยมันเองจะสร้างความเสียหายที่อาการไปโผล่อีกที่จนแทบไม่เกี่ยวข้อง มักเป็นตอนเก็บกวาดออบเจกต์อื่น
ทำไมเปิดเช็ก CRL แล้วถึงปฏิเสธลายเซ็นทุกตัว?
เพราะ OpenSSL เช็ก CRL เทียบกับสิ่งที่ store ถืออยู่แค่นั้น และไม่ออกไปดึงอะไรเองเลย มันไม่ตาม CRL distribution points และไม่พูดภาษา OCSP ตั้ง X509_V_FLAG_CRL_CHECK บน store ที่ไม่มี CRL อยู่เลย ห่วงโซ่ทุกตัวจะล้มด้วยข้อหาดึง certificate CRL ไม่ได้ ผลลัพธ์หน้าตาเหมือนการเช็กการเพิกถอนทำงานอยู่แล้วเจอปัญหา ทั้งที่จริงคือการเช็กการเพิกถอนไม่เคยถูกรันเลยแม้แต่ครั้งเดียว
backend จึงตั้งแฟล็กนี้เมื่อ ConfigureSslCrls ส่ง CRL มาให้อย่างน้อยหนึ่งตัวจริง ๆ เท่านั้น ถ้าไม่มีเลย RevocationStatus จะกลับมาเป็น pcvsUnsupported ซึ่งเป็นคำพูดตรงไปตรงมาว่าคำถามข้อนี้ไม่ได้รับคำตอบ ด้วยเหตุผลเดียวกัน OnlineRetrieval ไม่มีผลกับ backend ตัวนี้และไม่มี checkpoint pcvstOnlineRetrieval ถูกปล่อยออกมา เพราะไม่มีเส้นทางดึงข้อมูลให้รายงานความคืบหน้า
นี่คือจุดยืนเชิงดีไซน์ที่คุ้มค่าจะปกป้องไว้ในกรณีทั่วไป validator ที่เช็กการเพิกถอนไม่ได้ควรพูดออกมาตรง ๆ การรายงานใบรับรองที่ไม่ได้เช็กว่าไม่ถูกเพิกถอน คือหนทางที่พบมากที่สุดที่เครื่องมือตรวจลายเซ็นหลอกผู้ใช้ของตัวเอง และมันเป๊ะกับตระกูลความสับสนที่สำรวจไว้ในบทความทำไม validator ปฏิเสธลายเซ็น PAdES
// Checkpoint ช่วยให้ UI แสดงได้ว่ากำลังทำสเตจไหนอยู่ และบอกคุณว่า
// backend ทำสเตจไหนจริง
type
TSignatureProbe = class
procedure Checkpoint(Stage: TPdfCmsVerifyStage);
end;
procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
case Stage of
pcvstCryptographicSignature: Status('checking the signature');
pcvstChainBuild: Status('building the certificate chain');
pcvstOnlineRetrieval: Status('fetching validation data');
pcvstRevocationCheck: Status('checking revocation');
end;
end;
// อ่านคำตัดสินสามตัวแยกกัน เพราะมันได้รับอนุญาตให้ไม่เห็นพ้องกัน
if Result.SignatureStatus = pcvsValid then
case Result.TrustStatus of
pcvsValid: Report('signed and trusted');
pcvsInvalid: Report('signed, chain rejected');
pcvsUnsupported,
pcvsIndeterminate: Report('signed, trust not established');
end;
if Result.RevocationStatus = pcvsUnsupported then
Report('revocation was not checked on this backend');
ผูกกับไลบรารีที่คุณตรึงเวอร์ชันไม่ได้
OpenSSL เปลี่ยนชื่อ accessor ของ stack ระหว่าง 1.0 ไป 1.1 ฟังก์ชันตรรกะตัวเดียวกันจึงมีชื่อเอ็กซพอร์ตได้สองชื่อ ขึ้นกับบิลด์ที่เครื่องโฮสต์บังเอิญมี binding จะ resolve ชื่อใหม่ก่อนแล้วค่อยถอยไปชื่อเก่า และจะบันทึกว่าสัญลักษณ์หายเมื่อสองชื่อไม่ผ่านเลยเท่านั้น นั่นคือรูปทรงที่ถูกของ dynamic binding กับไลบรารีที่คุณไม่ได้แถมไปกับผลิตภัณฑ์: เอาชื่อปัจจุบันก่อน อดทนกับชื่อเก่า และรายงานเฉพาะการขาดหายที่แท้จริง
SslMissingSymbols คือสิ่งที่เปลี่ยนการโหลดที่ล้มให้กลายเป็นเหตุการณ์ที่วินิจฉัยได้ ผลลัพธ์ไม่ว่างบนเครื่องที่เห็นชัดว่าติดตั้ง libcrypto อยู่ แปลว่าเวอร์ชันที่ติดตั้งไว้เก่ากว่า API ที่บิลด์นี้เล็งมา ซึ่งเป็นบทสนทนาซัพพอร์ตคนละเรื่องโขกับกรณีไลบรารีหายไปเลย ส่วน ConfigureSslLibraryPath รองรับกรณีที่พบบ่อยอีกแบบ คือเครื่องที่มีบิลด์ OpenSSL หลายตัวและตัวบน search path ค่าเริ่มต้นไม่ใช่ตัวที่คุณอยากได้
เลือก backend ตามแพลตฟอร์ม
แบบแผนที่ใช้ได้จริงคือเลือกตอนสตาร์ตแล้วบันทึกไว้ว่าตัวไหนตอบ บน Windows backend ของแพลตฟอร์มผนึกกับ certificate store ที่องค์กรจัดการอยู่แล้ว ซึ่งปกติคือสิ่งที่คุณต้องการ บน macOS backend Keychain ก็ใช้เหตุผลเดียวกันนี้ เล่าไว้ในบทความตรวจลายเซ็นด้วย SecTrust บน macOS OpenSSL เป็นตัวเลือกที่พกพาได้ และยังเป็นทางเลือกที่ถูกเมื่อคุณต้องการนโยบายการตรวจสอบที่เหมือนกันเป๊ะทุกแพลตฟอร์ม มากกว่านโยบายที่ไล่ตาม trust store ของแต่ละแพลตฟอร์ม
ไม่ว่าจะติดตั้งตัวไหน ให้บันทึก PadesCmsVerificationBackendName ไว้ข้างคำตัดสินทุกตัวที่คุณเก็บ ผลการตรวจสอบที่เก็บไว้โดยไม่มี backend ที่ผลิตมันออกมาจะทำซ้ำทีหลังไม่ได้ เพราะสถานะสามตัวแปลความหมายได้ต่างกันประณีต ขึ้นกับว่า stack ตัวไหนเป็นผู้ตอบ ชั้นสำรวจลายเซ็นที่วางอยู่บนสุดของทั้งหมดนี้ รวมถึงวิธีรายงานระดับ PAdES มีเล่าไว้ในบทความสำรวจลายเซ็นดิจิทัล PDF และระดับ PAdES
ทั้งหมดมาพร้อมซอร์สกับPDFium Delphi component ซึ่งในที่นี้สำคัญกว่าปกติ: สำหรับ validator ลายเซ็น ความสามารถอ่านดูได้ว่า backend ตั้งแฟล็กอะไรบ้างและข้ามการเช็กตัวไหนไป ไม่ใช่ของแถม มันคือทางเดียวที่จะรู้ว่าเครื่องหมายถูกสีเขียวในแอปของคุณกำลังอ้างสิทธิ์อะไรกันแน่