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

ตรวจสอบลายเซ็น PDF ด้วย OpenSSL ใน PDFium VCL

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 ถูกปล่อยออกมา เพราะไม่มีเส้นทางดึงข้อมูลให้รายงานความคืบหน้า

แผนภาพตัวตรวจสอบ CMS ด้วย OpenSSL ของ PDFium VCL กับกับดักสามข้อ: content BIO ที่ใช้ร่วมกันถูกอ่านจนจบไฟล์ทำให้รอบตรวจสอบที่สองเหลือเนื้อหาศูนย์ไบต์, CMS_NO_SIGNER_CERT_VERIFY กดการประเมินห่วงโซ่แต่ไม่กดการค้นหาผู้ลงนาม และการเช็ก CRL บน store ว่างเปล่าปฏิเสธทุกห่วงโซ่โดยที่การเพิกถอนไม่เคยรันเลย
กับดักแต่ละข้อให้คำตัดสินผิดอย่างมั่นใจ: ตำแหน่งสตรีมแอบแฝงตัวเป็นความล้มเหลวด้านความเชื่อถือ แฟล็ก no-verify กดได้น้อยกว่าที่ชื่อมันฟ้อง และการเพิกถอนที่ไม่เคยรันหน้าตาเหมือนการเพิกถอนที่เจอปัญหา

นี่คือจุดยืนเชิงดีไซน์ที่คุ้มค่าจะปกป้องไว้ในกรณีทั่วไป 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 ของแต่ละแพลตฟอร์ม

แผนภาพ abstraction ของ IPdfCmsVerifier ใน PDFium VCL ที่แบก VerifyDetached ครอบช่วงไบต์สองช่วงรอบรู Contents กับ VerifyAttached สำหรับ token ประทับเวลา, คำตัดสินอิสระสามตัว SignatureStatus, TrustStatus และ RevocationStatus และ backend ต่อแพลตฟอร์มที่เลือกตอนสตาร์ตผ่าน CryptoAPI, SecTrust หรือ ConfigureSslCmsVerifier
อินเทอร์เฟซแบกรูปทรงการตรวจสอบสองแบบกับคำตัดสินสามตัว เพราะแต่ละตัวตอบคนละคำถามและเห็นต่างกันได้ ส่วน backend ที่ติดตั้งถูกบันทึกไว้เคียงข้างคำตัดสินทุกตัว ผลลัพธ์ที่เก็บไว้จึงทำซ้ำได้

ไม่ว่าจะติดตั้งตัวไหน ให้บันทึก PadesCmsVerificationBackendName ไว้ข้างคำตัดสินทุกตัวที่คุณเก็บ ผลการตรวจสอบที่เก็บไว้โดยไม่มี backend ที่ผลิตมันออกมาจะทำซ้ำทีหลังไม่ได้ เพราะสถานะสามตัวแปลความหมายได้ต่างกันประณีต ขึ้นกับว่า stack ตัวไหนเป็นผู้ตอบ ชั้นสำรวจลายเซ็นที่วางอยู่บนสุดของทั้งหมดนี้ รวมถึงวิธีรายงานระดับ PAdES มีเล่าไว้ในบทความสำรวจลายเซ็นดิจิทัล PDF และระดับ PAdES

ทั้งหมดมาพร้อมซอร์สกับPDFium Delphi component ซึ่งในที่นี้สำคัญกว่าปกติ: สำหรับ validator ลายเซ็น ความสามารถอ่านดูได้ว่า backend ตั้งแฟล็กอะไรบ้างและข้ามการเช็กตัวไหนไป ไม่ใช่ของแถม มันคือทางเดียวที่จะรู้ว่าเครื่องหมายถูกสีเขียวในแอปของคุณกำลังอ้างสิทธิ์อะไรกันแน่