Bài viết kỹ thuật

Verify chữ ký PDF bằng OpenSSL trong PDFium VCL

PDFium VCL coi CMS verification như một backend có thể hoán đổi sau interface IPdfCmsVerifier, để PAdES validator chạy trên Windows qua CryptoAPI, trên macOS qua Keychain, và ở bất cứ đâu có OpenSSL qua ConfigureSslCmsVerifier. Interface thì nhỏ. Ba hành vi OpenSSL nằm dưới nó sẽ cho những câu trả lời sai đầy tự tin nếu bạn triển khai một cách ngây thơ

Động cơ thì rõ ràng ngay khi một application Delphi rời Windows. Signature validation là một trong ít lĩnh vực mà crypto stack của platform không phải một chi tiết triển khai: nó quyết định chứng chỉ nào được tin, thuật toán nào tồn tại, và revocation nghĩa là gì. Hard-code một cái thì code không port được. Abstract kém thì mỗi platform báo một hình dạng câu trả lời khác nhau mà caller không so sánh nổi

Abstraction thực sự phải mang theo những gì

Hai hình dạng verification và ba phán quyết độc lập. Một PDF signature là detached: nội dung được ký là hai dải byte ở hai bên lỗ /Contents, nên VerifyDetached nhận hai segment thay vì một buffer. Một timestamp token là attached, tự mang content theo, nên VerifyAttached chỉ nhận DER

Kết quả tách thành ba status vì chúng trả lời ba câu hỏi khác nhau và có thể không đồng tình với nhau. SignatureStatus nói bytes có được ký bởi key trong signer certificate hay không. TrustStatus nói certificate đó có nối tới thứ bạn tin hay không. RevocationStatus nói certificate có còn hiệu lực tại thời điểm liên quan hay không. Một tài liệu với chữ ký hoàn hảo về mặt toán học từ một certificate bạn chưa từng nghe tên là valid, untrusted và unknown, và việc gộp cả ba thành một boolean duy nhất là cách các validator kết thúc bằng việc nói dối người dùng

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, có thể rỗng
  ConfigureSslCrls(LoadFreshCrls);                // DER, có thể rỗng
  ConfigureSslCmsVerifier;                        // cài đặt 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 trông như một món tò mò và không phải vậy. Mỗi OpenSSL error code và mọi store flag vượt qua ranh giới dưới dạng C unsigned long, vốn là bốn byte trên Windows và tám byte trên Linux cùng macOS. Khai báo nó thành một type 32-bit cố định thì code chạy trên Windows, rồi âm thầm đọc nửa giá trị trên LP64. Báo các width đang được giả định dưới dạng một chuỗi assert được trong test biến cả một hạng platform ABI drift thành một phép check một dòng. Ai từng vật lộn với cùng vấn đề qua CK_ULONG trong một binding PKCS#11 sẽ nhận ra ngay; câu chuyện đó nằm trong PKCS#11 struct packing và độ rộng CK_ULONG

Vì sao pass verify thứ hai lại thấy content rỗng?

CMS_verify đọc content BIO detached tới cuối file, và một BIO đã bị đọc thì không tự rewind giùm bạn. Verify thành hai pass là một thiết kế hợp lý — pass đầu chỉ chữ ký thuần với chain evaluation bị tắt, pass sau đánh giá trọn vẹn — và nó fail theo một cách lừa người một cách bất thường nếu cả hai pass dùng chung một BIO

Pass thứ hai nhận đúng không byte content nào. Ở chế độ detached, đó không phải một error, vì một content buffer rỗng là input hợp lệ. Digest đơn giản không khớp, và failure lộ diện dưới dạng một chain-building failure thay vì một content failure — thứ đẩy bạn đi soi certificate và trust store trong khi vấn đề thật chỉ là một stream position. Hãy dựng lại memory BIO bằng BIO_new_mem_buf cho từng pass. Cái giá là một lần cấp phát, và khả năng xảy ra biến mất hoàn toàn

Flag no-verify tắt được gì và không tắt gì

CMS_NO_SIGNER_CERT_VERIFY tắt chain evaluation, không tắt signer certificate lookup. Bên trong, OpenSSL resolve và gắn các signer certificate trước khi tham khảo flag, nên sau một pass đầu mang flag đó, signer đã có sẵn và các algorithm identifier của nó đọc được ngay. Không cần chạy một lần verify đầy đủ thứ hai chỉ để lấy signer certificate — đúng điều mà cái tên flag gợi ý cho bạn làm theo

Một luật sở hữu đi kèm. Reference signer thuộc về cấu trúc CMS và không được free độc lập. Nó hợp lệ chừng nào cấu trúc còn hợp lệ, và việc free nó tạo ra một corruption mà triệu chứng xuất hiện ở một nơi hoàn toàn khác, thường là lúc dọn dẹp một object không liên quan

Vì sao bật CRL checking lại từ chối mọi chữ ký?

Vì OpenSSL chỉ check CRL với những gì store đang giữ và không tự đi lấy gì cả. Nó không theo CRL distribution point và không nói OCSP. Set X509_V_FLAG_CRL_CHECK lên một store không có CRL nào và mọi chuỗi fail với một bất khả thu thập certificate CRL. Kết quả trông như revocation checking đang chạy và tìm ra vấn đề. Thực ra là revocation checking chưa từng chạy

Vì thế backend chỉ set flag khi ConfigureSslCrls thực sự được cung cấp ít nhất một CRL. Không có CRL nào, RevocationStatus quay về pcvsUnsupported — một lời phát biểu trung thực rằng câu hỏi chưa được trả lời. Vì cùng lý do, OnlineRetrieval không tác dụng gì với backend này và không checkpoint pcvstOnlineRetrieval nào được emit: chẳng có đường fetching nào để báo tiến độ

Sơ đồ ba cái bẫy của OpenSSL CMS verifier trong PDFium VCL: một content BIO dùng chung bị đọc tới cuối file để pass verify thứ hai với đúng không byte, CMS_NO_SIGNER_CERT_VERIFY tắt chain evaluation nhưng không tắt signer lookup, và CRL checking trên một store rỗng từ chối mọi chuỗi trong khi revocation chưa từng chạy
Mỗi cái bẫy cho ra một phán quyết sai đầy tự tin: một stream position giả dạng một trust failure, flag no-verify tắt ít hơn cái tên gợi ý, và revocation chưa từng chạy trông như revocation đã tìm ra vấn đề

Đây là một thế đứng thiết kế đáng bảo vệ nói chung. Một validator không check được revocation nên nói vậy. Báo một certificate chưa được kiểm tra là chưa bị thu hồi là cách phổ biến nhất khiến các công cụ signature validation đánh lừa người dùng, và đúng hạng nhầm lẫn được mổ xẻ trong vì sao validator từ chối PAdES signature

// Checkpoint để một UI hiển thị đang chạy stage nào, và cho biết
// backend thật sự thực hiện những stage nào
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;

// Đọc ba phán quyết tách biệt; chúng được phép không đồng tình
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');

Bind vào một library bạn không ghim được

OpenSSL đổi tên các stack accessor giữa 1.0 và 1.1, nên cùng một hàm logic có hai tên export khả dĩ tùy bản build máy chủ tình cờ có. Phần binding resolve tên mới trước rồi fallback về tên cũ, và chỉ ghi một missing symbol khi cả hai đều không resolve được. Đó là hình dáng đúng cho bất kỳ dynamic binding nào vào một library bạn không ship kèm: ưu tiên tên hiện hành, khoan dung tên lịch sử, và chỉ báo cáo sự vắng mặt thật

SslMissingSymbols là thứ biến một lần load thất bại thành một sự kiện chẩn đoán được. Một kết quả không rỗng trên một máy rõ ràng đã cài libcrypto nghĩa là version đang cài cũ hơn API mà bản build này nhắm tới — một câu chuyện support hoàn toàn khác với trường hợp library vắng mặt. ConfigureSslLibraryPath phủ nốt case phổ biến còn lại: một máy có vài bản build OpenSSL mà bản nằm trên default search path không phải bản bạn muốn

Chọn backend theo từng platform

Bố cục thực dụng là chọn lúc khởi động và ghi lại bên nào đã trả lời. Trên Windows, platform backend tích hợp với các certificate store mà doanh nghiệp đã quản lý sẵn — thường là điều bạn muốn. Trên macOS, Keychain backend khớp cùng lý lẽ và được mô tả trong verify chữ ký bằng SecTrust trên macOS. OpenSSL là phương án portable, và cũng là lựa chọn đúng khi bạn cần một validation policy đồng nhất giữa các platform thay vì một policy đi theo trust store của từng platform

Sơ đồ PDFium VCL của abstraction IPdfCmsVerifier mang VerifyDetached trên hai dải byte bao quanh lỗ Contents cùng VerifyAttached cho timestamp token, ba phán quyết độc lập SignatureStatus, TrustStatus và RevocationStatus, và các backend theo platform được chọn lúc khởi động qua CryptoAPI, SecTrust hay ConfigureSslCmsVerifier
Interface mang hai hình dạng verification và ba phán quyết vì chúng trả lời những câu hỏi khác nhau và được phép không đồng tình, và backend được cài được ghi lại cạnh mọi phán quyết để kết quả đã lưu tái hiện được

Cài bên nào đi nữa, hãy log PadesCmsVerificationBackendName cạnh mọi phán quyết bạn ghi lại. Một kết quả validation đã lưu thiếu backend đã sinh ra nó thì không tái hiện được về sau, vì ba status mang những sắc thái khác nhau tùy stack nào trả lời. Tầng soi chữ ký đứng trên tất cả những thứ này, gồm cả cách báo PAdES level, được nói trong soi PDF digital signature và PAdES level

Toàn bộ đi kèm dạng source với PDFium Delphi component, và ở đây điều đó có ý nghĩa hơn thường lệ: với một signature validator, đọc được chính xác backend set những flag nào và bỏ qua những check nào không phải một thứ có cũng được — đó là cách duy nhất biết được một dấu check xanh trong application của bạn thật sự tuyên bố điều gì