技術文章

在 PDFium VCL 用 OpenSSL 驗證 PDF 簽章

PDFium VCL 把 CMS 驗證當成 IPdfCmsVerifier 介面背後一個可替換的後端,所以 PAdES 驗證器能在 Windows 上走 CryptoAPI、在 macOS 上走 Keychain、在任何有 OpenSSL 的地方走 ConfigureSslCmsVerifier。介面很小。而它底下有三種 OpenSSL 行為,天真相地實作的話,它們會信心滿滿地給出錯的答案

一旦 Delphi 應用程式離開 Windows,動機就再明白不過。簽章驗證是少數幾個平台密碼學堆疊不算實作細節的領域之一:它決定哪些憑證受信任、哪些演算法存在、撤銷是什麼意思。寫死一個,程式碼就移植不了;抽象得不好,每個平台回報形狀各異的答案,呼叫端無從比較

這個抽象實際上要承載什麼

兩種驗證形狀,三個獨立判定。PDF 簽章是分離式的:被簽的內容是 /Contents 洞兩側的那兩段位元組範圍,所以 VerifyDetached 收兩個區段,不是一個緩衝區。時間戳記權杖是附加式的,自帶內容,所以 VerifyAttached 只收 DER

結果拆成三個狀態,因為它們回答三個不同的問題,而且可以互相矛盾。SignatureStatus 說那些位元組是不是由簽署者憑證裡的金鑰簽的。TrustStatus 說那張憑證能不能鏈到您信任的東西。RevocationStatus 說憑證在相關時間點是否仍然有效。一份數學上完美、但簽署者憑證您聞所未聞的文件,狀態是有效、不受信任、未知;把這些壓成一個布林,驗證器就是這樣開始對使用者說謊的

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;                        // 安裝後端

  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 看起來像個冷知識,其實不是。每個 OpenSSL 錯誤碼與每個 store 旗標都以 C 的 unsigned long 跨過邊界,在 Windows 上是四個位元組,在 Linux 與 macOS 上是八個。宣告成固定 32 位元型別,程式碼在 Windows 上好好跑,然後在 LP64 上默默只讀到半個值。把假設的寬度回報成一個能在測試裡斷言的字串,整個類別的平台 ABI 漂移就變成一行檢查。用 PKCS#11 繫結裡的 CK_ULONG 踩過同一個坑的人會立刻認出它;那個故事在PKCS#11 結構封裝與 CK_ULONG 寬度

為什麼第二輪驗證看到的是空內容?

因為 CMS_verify 會把分離式內容的 BIO 讀到檔案結尾,而被讀過的 BIO 不會替您倒回。分兩輪驗證是合理的設計——第一輪只驗密碼學簽章、壓掉鏈評估,第二輪做完整評估——但如果兩輪共用同一個 BIO,它會以一種特別會騙人的方式失敗

第二輪拿到的內容是零位元組。在分離模式下這不是錯誤,因為空的內容緩衝區是合法輸入。摘要就是不匹配,而失敗以建鏈失敗的形式浮現,不是內容失敗,於是您被支去檢查憑證與信任儲存庫,真正的問題其實是一個串流位置。每一輪都用 BIO_new_mem_buf 重建記憶體 BIO。代價是一次配置,換來的是這種可能性被整個移除

no-verify 旗標壓掉什麼、沒壓掉什麼

CMS_NO_SIGNER_CERT_VERIFY 壓掉的是鏈評估,不是簽署者憑證的查找。OpenSSL 內部在查那個旗標之前就會解析並掛上簽署者憑證,所以帶著這個旗標跑完第一輪之後,簽署者已經在手上,它的演算法識別碼可以直接讀。不需要為了拿簽署者憑證再跑一次完整驗證——旗標的名稱正是在誘您做這個假設

跟著來的是一條所有權規則。簽署者參照屬於 CMS 結構,不得獨立釋放。結構活多久它活多久,釋放它造成的損壞,症狀出現在完全不相干的地方,通常是在清理某個無關物件時

為什麼打開 CRL 檢查之後每個簽章都被拒?

因為 OpenSSL 只對 store 已經持有的 CRL 做檢查,自己什麼都不抓。它不追 CRL 發佈點,也不會講 OCSP。在一個沒有 CRL 的 store 上設 X509_V_FLAG_CRL_CHECK,每條鏈都以「無法取得憑證 CRL」失敗。結果看起來像撤銷檢查正常運作、還找到了問題;實際上是撤銷檢查從頭到尾沒跑過

所以後端只在 ConfigureSslCrls 真的供應了至少一份 CRL 時才設那個旗標。沒有 CRL,RevocationStatus 就回 pcvsUnsupported,這是對「這個問題沒有被回答」的誠實陳述。基於同樣的理由,OnlineRetrieval 在這個後端上沒有效果、也不會發出 pcvstOnlineRetrieval 檢查點:根本沒有抓取路徑可以回報進度

PDFium VCL OpenSSL CMS 驗證器三個陷阱的圖解:共用的內容 BIO 被讀到檔尾,第二輪驗證只拿到零位元組;CMS_NO_SIGNER_CERT_VERIFY 壓掉鏈評估、卻沒壓掉簽署者查找;空 store 上的 CRL 檢查拒絕每條鏈,而撤銷檢查從未運行
每個陷阱都產出一個信心滿滿的錯誤判定:串流位置假扮成信任失敗,no-verify 旗標壓掉的比名稱暗示的少,從未運行的撤銷檢查看起來像找到了問題的撤銷檢查

這是一個值得普遍捍衛的設計立場。無法檢查撤銷的驗證器就該這麼說。把一張未經檢查的憑證回報成未被撤銷,是簽章驗證工具誤導使用者的頭號方式,也正是驗證器為什麼拒收 PAdES 簽章一文探討的那類困惑

// Checkpoint 讓 UI 顯示正在跑哪個階段,也告訴您
// 某個後端實際執行哪些階段
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 在 1.0 到 1.1 之間改了堆疊存取子的名稱,同一個邏輯函式因此有兩個可能的匯出名稱,取決於主機碰巧裝的建置。繫結先解析較新的名稱、退回較舊的,兩個都解析不到才記錄缺失符號。這是對任何不隨您出貨的程式庫做動態繫結的正確形狀:偏好當代名稱,容忍歷史名稱,只回報真正的缺席

SslMissingSymbols 是把一次載入失敗變成可診斷事件的關鍵。在一台明顯裝有 libcrypto 的主機上得到非空結果,代表裝的版本比這個建置瞄準的 API 還舊——這與「程式庫根本不在」是完全不同的兩場支援對話。ConfigureSslLibraryPath 覆蓋另一個常見情況:主機上有多套 OpenSSL 建置,而預設搜尋路徑上那套不是您要的

按平台挑選後端

實務上的安排是啟動時選定,並記錄是誰回答的。Windows 上,平台後端與企業既有的憑證儲存庫整合,這通常正是您要的。macOS 上,Keychain 後端符合同樣的道理,記錄在在 macOS 用 SecTrust 驗證簽章。OpenSSL 是可攜的選項,當您要的是跨平台完全一致的驗證政策、而不是各自跟著平台信任儲存庫走時,它也是對的選擇

PDFium VCL 的 IPdfCmsVerifier 抽象圖解:承載對 Contents 洞兩側兩段位元組範圍的 VerifyDetached、與時間戳記權杖的 VerifyAttached,三個獨立判定 SignatureStatus、TrustStatus、RevocationStatus,以及啟動時經 CryptoAPI、SecTrust 或 ConfigureSslCmsVerifier 選定的各平台後端
介面承載兩種驗證形狀與三個判定,因為它們回答不同的問題、而且可能不一致;安裝的後端記在每個判定旁邊,儲存的結果因此可以重現

不管裝哪個,每記錄一個判定,旁邊就記一筆 PadesCmsVerificationBackendName。一份沒標產出後端的儲存驗證結果,之後無法重現,因為那三個狀態值的微妙語意取決於是哪個堆疊回答的。在這一切之上的簽章檢視層——包括 PAdES 等級怎麼回報——記錄在檢視 PDF 數位簽章與 PAdES 等級

這一切以原始碼形式隨 PDFium Delphi component 出貨,而這一點在此比平常更要緊:對一個簽章驗證器來說,能讀到後端確切設了哪些旗標、跳過了哪些檢查,不是有最好、沒有也行——那是知道您應用程式裡那個綠色勾勾到底宣稱了什麼的唯一方法