技術文章

Delphi 在 macOS 使用 SecTrust 驗證 PDF 簽章

PDFium Delphi Component 在 macOS 上透過 TPdfKeychainCmsVerifier 驗證 PDF 簽章,這是一個建立在 Apple CMSDecoder 和 SecTrust 之上的 CMS 驗證後端,而不是手工解析 CMS。ConfigureKeychainCmsVerifier 安裝它,單次 CMSDecoderCopySignerStatus 呼叫就會回傳簽章結論、SecTrust 控制代碼和憑證結果代碼,正好對應 TPdfCmsVerifyResult 在 Windows 上已經攜帶的兩欄

促成這項工作的情境很普通。文件歸檔的 Lazarus 建置在 Mac 上執行,開啟一份已簽署合約,所有簽章都回傳 pcsUnsupported。檔案沒有問題。簽章驗證在 Windows 之外根本沒有後端,而 PAdES 驗證器在缺少後端時拒絕猜測。PDFiumPas 3.111.0 透過 IPdfCmsVerifierConfigurePadesCmsVerifier 打開了接縫;3.113.0 又在 macOS 上填補了它。移植中真正有意思的不是連接程式碼,而是 Apple API 與 Windows API 形狀不同的三個地方

PDF 簽章為什麼覆蓋兩個位元組範圍

因為簽章不能覆蓋儲存簽章本身的位元組。ISO 32000-1 第 12.8.1 節將 CMS SignedData 資料區塊放入簽名字典的 /Contents 字串,並透過 /ByteRange 描述簽章覆蓋範圍;它是一組偏移和長度對,覆蓋這個空洞兩側的所有內容。無論在哪個平台,都是兩個片段,中間有一個間隙

這些片段如何進入加密層,平台之間並不一致,而這種差異會消耗記憶體。在 Windows 上,CryptVerifyDetachedMessageSignature 接受指標和長度陣列,因此兩個範圍可以按它們在緩衝區中的原樣傳入,不需要複製。Apple 的 CMSDecoderSetDetachedContent 只接受一個 CFData,沒有多段形式,因此 macOS 後端必須先把兩個範圍串接到連續緩衝區中再解碼。這相當於完整複製第二份簽章位元組。在 400 MB 的掃描歸檔上,這是真實的記憶體峰值,它隨文件而不是隨簽章大小增長,也沒有其他 API 可用。應當據此規劃批次工作程序,而不是在客戶機器上才發現

一次呼叫填入 TPdfCmsVerifyResult 的兩欄

CMSDecoderCopySignerStatus 對 Security.framework 入口點來說異常慷慨:一次呼叫回傳簽署者狀態、它建立的鏈對應的 SecTrustRef,以及憑證評估的 OSStatus。這些值會直接進入 PAdES 驗證器已經消費的記錄,簽署者狀態成為 SignatureStatus,憑證結果成為 TrustStatus,原始值還保存在 SignatureErrorTrustError 中,這樣支援工單可以引用數字而不是形容詞。呼叫方永遠不會直接接觸 IPdfCmsVerifier——ValidatePadesComplianceValidatePadesTrust 會將每次驗證路由到已安裝的後端,因此讀取 TPadesSignatureValidation 的程式碼在兩個平台上逐位元組相同,正如在 Delphi 中檢查 PDF 數位簽章字典和 PAdES 等級的說明所述

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // 簽章和驗證解析不同的框架符號,因此一方可能存在
  // 而另一方不存在
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName 現在回傳 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

kCMSSignerInvalidCert 為什麼會回報有效簽章

因為 Apple 賦予這個值的含義比名稱暗示的範圍更窄:簽章本身已經驗證通過,只有憑證鏈無法建立。TPdfKeychainCmsVerifier 因此會將 kCMSSignerInvalidCert 映射為 SignatureStatus 欄中的 pcvsValid,讓憑證問題透過 TrustStatus 暴露,因為鏈問題就應當屬於那裡。將它折疊進簽章結論,會讓元件告訴操作員一份完整性未受破壞的文件已被修改,這是簽章驗證器能發出的最糟糕誤報

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // 簽章已驗證通過,只有鏈沒有通過,這由信任狀態
  // 個別回報
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

把兩個狀態作為有序對讀取,回報邏輯就會自行展開。SignatureStatus = pcvsValidTrustStatus = pcvsInvalid 一起表示:文件位元組完好,但目前 Mac 不信任其簽發者,可能是 Keychain 中缺少信任錨點、過期的中介憑證,或離線時無法完成的鏈。這是操作員策略問題,而不是文件完整性問題;這個區分正是為什麼驗證器會拒絕密碼學上正確的 PAdES 簽章中大多數案例背後的原因

macOS 在哪裡實際檢查撤銷

檢查發生在信任評估內部,這也是 TPdfCmsVerifyResult.RevocationStatus 跟在 TrustStatus 之後,而不是擁有獨立結論的原因。SecPolicyCreateRevocation 產生一個策略,該策略與 SecPolicyCreateBasicX509 一起放進傳給 CMSDecoderCopySignerStatus 的陣列,OCSP 或 CRL 工作發生在建立憑證鏈的過程中。API 不會回傳獨立答案,因此回報一個獨立結果就意味著憑空捏造。陣列本身還有一條值得命名的所有權規則:CFArrayCreate 會保留兩個策略,因此兩個區域參照可以立即釋放;只有一個策略時則完全跳過陣列,直接傳入策略,API 也接受這種形式

離線操作是明確旗標,而不是網路連線剛好斷開後的偶然結果。當 TPadesTrustValidationOptions.OnlineRetrieval 為 False 時,後端會加入 kSecRevocationNetworkAccessDisabled,將評估限制在機器上已經快取的回應;檢查點回呼仍按 Windows 後端回報的相同順序觸發 pcvstCryptographicSignaturepcvstChainBuildpcvstRevocationCheck。應用程式碼透過更高層的選項記錄設定這一切

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // 僅使用快取回應
  Options.CheckTimeStamps:= True;

  Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Report:= ValidatePadesTrust(Stream, Options);
  finally
    Stream.Free;
  end;

  if Report.SignatureCount= 0 then
    Log('No signature dictionary in this document')
  else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
    Log('Document integrity failed')
  else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
    Log('Bytes intact, chain not trusted on this Mac');
end;

Get 與 copy:在別處才失敗的釋放

SecTrustGetCertificateAtIndex 採用 get 語意,回傳的參照絕不能釋放;同一例程附近的 CMSDecoderCopySignerCertSecCertificateCopyData 採用 copy 語意,必須釋放。Core Foundation 將整個規則編碼在函式名稱的一個動詞中,而型別系統完全不會強制它。釋放借用的參照時,呼叫點不會立即出問題:信任物件只會變得不可靠,崩潰會在稍後、某個看不出與憑證鏈有關的位置到達

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get 語意:這個參照是借用的,不在這裡釋放
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy 語意:這個參照由我們擁有,必須釋放
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

沒有後端回應時,驗證器保證什麼

它保證答案是 unsupported,而不是靜默通過。如果 ConfigurePadesCmsVerifier 沒有安裝任何內容,平台預設後端也無法幫助,TPdfCmsVerifyResult 的每一欄都會是不可用,PAdES 驗證器會將它映射為 pcsUnsupported;因此沒有加密後端的建置會誠實回報,而不會聲稱簽章有什麼結果。macOS 繫結也同樣保守:Security.framework 和 CoreFoundation 透過 dlopendlsym 存取,因此缺少框架或繫結寫錯符號名稱,會表現為 KeychainVerificationAvailable 回傳 False,並由 KeychainMissingSymbols 指出問題符號,而不是連結失敗,也不是錯誤結論。這與元件尋找原生程式庫時採用的失敗關閉姿態相同,詳見在任意目標平台載入 PDFium 原生程式庫的文章

在 PDF 堆疊中,簽章驗證屬於靜默錯誤比明確不可用更糟的部分,而 macOS 給了你一個足夠強大的 API,讓兩種結果都很容易達到。串接位元組範圍並接受這次複製,將簽章結論和憑證鏈結論保存在不同欄中,尊重 get 與 copy 這兩個動詞,讓缺少後端明確說出自己缺少。如果你要把 Delphi 或 Free Pascal 文件流程移植到 Mac,並需要兩端都支援 PAdES 簽章和驗證,PDFium Delphi Component 會在統一介面後同時提供 Keychain 後端和 Windows 後端