Bài viết kỹ thuật

Kiểm tra revocation PDF offline trên Windows trong Delphi

PDFium VCL kiểm tra revocation chữ ký PDF offline trên Windows bằng cách thêm CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY vào lượt revocation của CertGetCertificateChain, vì cờ cache-only dùng cho việc dựng chain chẳng phủ lấy retrieval CRL hay OCSP chút nào. Từ v3.119.1, một lần gọi ValidatePadesTrust offline giữ nguyên khỏi mạng, và một kết quả sạch đòi hỏi bằng chứng revocation thật sự cho từng chứng chỉ. Phần còn lại của bài là vì sao cả hai nửa của câu trên đều cần được sửa

Bối cảnh phơi bày vấn đề thì bình thường thôi. Một service validation chạy trên một host Windows bị khóa chặt, TPadesTrustValidationOptions.NetworkPolicy là ptnpOffline (cũng là mặc định), và người vận hành kỳ vọng mọi câu trả lời đến từ cache chứng chỉ local. Rồi có người để ý các request outbound tới một distribution point của CA trong log firewall, hay một batch job khựng hết UrlRetrievalTimeoutMs 15000 ms ở mỗi chữ ký. Chẳng gì trong code đòi hỏi mạng. Windows tự đi đó

Vì sao dựng chain offline vẫn fetch CRL trên Windows?

Vì CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL chỉ giới hạn phần URL retrieval mà việc dựng chain làm: fetch AIA issuer, cập nhật root và CTL. Tài liệu Microsoft cho CertGetCertificateChain nói rõ cờ này không áp dụng cho việc kiểm revocation. Revocation có công tắc riêng của nó, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), và thiếu nó thì các revocation provider thoải mái tải một CRL hay gửi một request OCSP dù lời gọi xung quanh trông có vẻ offline. PDFium VCL giờ OR cờ đó vào lượt revocation bất cứ khi nào OnlineRetrieval là False, chồng lên các cờ chain, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT và CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Chuyện này quan trọng hơn cả độ trễ: một request OCSP nói cho responder biết bạn đang xem chứng chỉ nào, đúng thứ một validator air-gapped phải tránh

Hai công tắc độc lập gác validation chữ ký PDF offline trên Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL chỉ giới hạn việc dựng chain như fetch AIA issuer và root, trong khi revocation cần CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, vì thiếu nó các provider vẫn tải CRL và gửi request OCSP làm lộ chứng chỉ đang được kiểm tra
PDFium VCL OR cờ revocation cache-only vào lượt revocation bất cứ khi nào OnlineRetrieval là False, nên một trust validation ptnpOffline giữ nguyên khỏi mạng cho cả hai lượt
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // False theo mặc định
  Options.CheckTimeStamps := True;
  // Offline giờ nghĩa là offline cả với revocation: chỉ CRL và OCSP
  // đã cache thôi, không checkpoint pcvstOnlineRetrieval nào được raise
  Report := Pdf.ValidatePadesTrust(Options);
end;

Hai lần dựng chain, hai slot lỗi

Backend Windows dựng chain hai lần, và mỗi lần dựng giờ sở hữu một slot lỗi riêng. Lần gọi CertGetCertificateChain đầu chạy không có cờ revocation và đưa CertVerifyCertificateChainPolicy với chính sách cơ bản, thứ sinh ra TrustStatus và TrustError. Lần gọi thứ hai thêm các cờ revocation. Trước v3.119.1, một thất bại của lần gọi thứ hai ghi GetLastError vào TrustError, nên một chain vừa được xác nhận tin cậy có thể quay về trông như không tin cậy chỉ vì một revocation provider hiccup. Bản sửa đọc GetLastError ngay lập tức và cất nó vào TPdfCmsVerifyResult.RevocationError, để nguyên phán quyết lượt đầu. Và một giá trị True trả về từ lần gọi thứ hai cũng không bị coi là thành công; nó chỉ có nghĩa là Windows đã đưa lại một chain context đáng soi

Một mặt lỗi trust bằng 0 thật sự chứng minh điều gì?

Một mình nó, chẳng chứng minh gì. Một TrustStatus.dwErrorStatus tổng hợp bằng 0 sau lượt revocation nói rằng chẳng bit lỗi nào được raise, và một chain mà chẳng element nào mang thông tin revocation có thể cho ra đúng kết quả đó. Code trước đây ánh xạ thẳng “không bit revoked, không bit unknown, không bit offline” sang valid, kiểu kinh điển mà một validator báo một chứng chỉ chưa được kiểm như một chứng chỉ sạch. Routine mới ReadWinRevocationEvidence đi qua mọi simple chain và mọi element, chối các cấu trúc mà cbSize nhỏ quá để đọc an toàn, và chỉ báo thành công khi tồn tại ít nhất một element khác root và mọi element như vậy mang một CERT_REVOCATION_INFO mà dwRevocationResult của nó bằng 0

Cuộc đi lấy bằng chứng mà ReadWinRevocationEvidence thực hiện trên từng element chain Windows trong Delphi: pRevocationInfo phải có mặt, cbSize phải đủ lớn để đọc, dwRevocationResult phải bằng 0, và element cuối chỉ bị loại khi được đánh dấu self-signed hay CA-trusted, nên một mặt lỗi trust bằng 0 không còn chui được một chứng chỉ chưa kiểm qua
Một phán quyết sạch đòi ít nhất một element khác root và một câu trả lời từ mọi element bắt buộc, với RevocationError giữ nguyên DWORD thô của provider như CRYPT_E_REVOKED
// Rút gọn từ evidence walk: một element chỉ được tính khi có
// một revocation provider thật sự trả lời cho nó
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); // một chain chỉ toàn root chẳng chứng minh gì

Kết quả của provider được giữ nguyên trạng. RevocationError giữ DWORD dwRevocationResult đúng như provider trả về, Ưu tiên lỗi từ element bị revoke khi có (CRYPT_E_REVOKED là $80092010), và bitmask trust không bao giờ được thay áo thành một mã lỗi native. Phép ánh xạ sang TPdfCmsRevocationReason là thô một cách có chủ ý: pcrrCertificateRevoked với pcvsInvalid cho revocation tường minh, pcrrChainUntrusted khi chain thất bại vì lý do chẳng liên quan revocation, và pcrrUnknown cho mọi thứ còn lại. Windows có thể đã thử OCSP thay vì CRL, nên một kết quả offline hay unknown không được dịch thành pcrrCrlExpired. Backend verify CMS OpenSSL có thể đưa ra các phân biệt riêng-CRL đó vì nó chỉ từng đánh giá những CRL bạn đưa cho nó, trong khi backend macOS SecTrust để các trường ở pcrrNone và 0, nghĩa là “không có chẩn đoán chi tiết”, chứ không phải “đã qua”

Việc loại root dừng ở đâu

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT bỏ anchor một cách chính đáng, vì chẳng ai phát hành một CRL revoke một root chống lại chính nó. Cái bẫy là quyết định element nào là root. PDFium VCL loại element cuối của một simple chain chỉ khi dwInfoStatus của nó đánh dấu self-signed ($00000008) hay được CA tin cậy tường minh ($00004000). Một host offline thường không fetch nổi một issuer còn thiếu, nên chain kết thúc ở một intermediate; coi element cuối của chain một phần đó là root sẽ lặng lẽ đánh rơi đúng chứng chỉ mà trạng thái revocation nhiều khả năng vắng mặt trong cache nhất. Element đó vẫn nằm trong tập bắt buộc, không có câu trả lời của provider nào, và kết quả giữ nguyên pcvsIndeterminate

Kết quả revocation của chữ ký và timestamp được tách nhau thế nào?

Bằng các trường riêng chẳng bao giờ ghi đè nhau. Validator PAdES verify CMS detached của chữ ký tài liệu và CMS attached của token timestamp RFC 3161 trong hai lần gọi độc lập, và v3.119.0 cho mỗi cái một bộ chẩn đoán riêng trên TPadesSignatureValidation: RevocationReason và NativeRevocationError cho người ký, TimeStampRevocationReason và NativeTimeStampRevocationError cho TSA. Một chứng chỉ TSA bị revoke vì thế không thể giả làm một người ký bị revoke, và một lần timestamp thất bại không xóa được một kết quả toàn vẹn đã được thiết lập. Khi CheckRevocation là False, hay validation chưa từng chạm tới chặng đó, các trường giữ pcrrNone và 0, nên hãy luôn đọc chúng cạnh RevocationStatus và TimeStampRevocationStatus

Revocation của chữ ký và timestamp ở tách rời trong PDFium VCL: CMS detached của chữ ký tài liệu đổ vào RevocationReason và NativeRevocationError, CMS attached của token RFC 3161 đổ vào TimeStampRevocationReason và NativeTimeStampRevocationError, và các chặng chưa kiểm để pcrrNone và 0 cạnh các trường trạng thái của chúng
Một chứng chỉ TSA bị revoke vì thế không thể giả làm một người ký bị revoke, và một lần timestamp thất bại không bao giờ xóa được một kết quả toàn vẹn mà việc verify chữ ký đã thiết lập
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;

Báo cáo bằng chứng đi theo cùng luật đó. Bản export CSV nối thêm revocationReason, nativeRevocationError và các cột timestamp tới nativeTimeStampRevocationError ở cuối thứ tự cột hiện có, nên các parser cũ vẫn chạy tiếp, và bản export JSON thêm các trường tương ứng mà không đổi ý nghĩa của các trường cũ. Nếu validation offline cứ trả về indeterminate mãi, bản sửa tận gốc nằm ở thượng nguồn: thu thập vật liệu validation ngay lúc ký, như được mô tả trong chữ ký PDF dài hạn với timestamp RFC 3161 và DSS, thay vì hy vọng máy verify có một cache ấm

Ma trận test chứng minh điều gì và không chứng minh điều gì

Ma trận verify Windows đã qua 30 kịch bản chain API được kiểm soát và một lần smoke CMS offline thật trên mỗi đích Win32 và Win64 của cả Delphi lẫn FPC. Smoke thật verify một chữ ký hợp lệ dưới một CA riêng không được tin cậy, trong khi các kết quả sạch và bị revoke tường minh đến từ các response CertGetCertificateChain được stub thay vì các trust anchor cài sẵn hay retrieval sống. Đó là một biên trung thực đáng nói ra: việc xử lý cờ, việc cô lập lỗi và cuộc đi lấy bằng chứng đã được ghim chặt, nhưng cache revocation của một máy cụ thể chứa gì vào một ngày cụ thể vẫn là chuyện của Windows, và một cache rỗng giờ đúng đắn cho ra “unknown” thay vì một request mạng hay một “valid” giả

Phần xử lý revocation offline, các chẩn đoán theo từng trường và các bản export bằng chứng là một phần của API validation chữ ký PDF trong PDFium VCL for Delphi and C++Builder, cùng các backend OpenSSL và macOS cho các triển khai đa nền tảng