技術文章

RFC 5280 對 PDF 簽章路徑驗證器的要求

一張已簽章的發票逐位元組驗證通過,簽署者上個月卻已被撤銷,而您抓回來的 CRL 根本沒列那個序號:它只為另一個分區說話。HotPDF 的路徑驗證就是圍繞這類情況設計的:RFC 5280 的範圍與信任錨規則、一個 UTC 評估時鐘,以及只有在 TSA 鏈受信任之後時間才算數的 RFC 3161 時間戳

評估時鐘走 UTC

路徑驗證器比較的每一個時刻都以 UTC 儲存。RFC 5280 §4.1.2.5 要求憑證有效期日期以格林威治時間的 UTCTime 或 GeneralizedTime 編碼,CRL 的 thisUpdate 與 nextUpdate 用同樣的編碼,RFC 6960 的 OCSP 回應則以 UTC 的 GeneralizedTime 帶 producedAt、thisUpdate 與 nextUpdate。Delphi 的 TDateTime 不帶時區,Now 回傳本地掛鐘時間,所以寫成 if Now > NotAfter 的檢查把兩個時鐘混在一起,編譯器連一聲都不吭。這個錯誤按地理位置分向發作:格林威治以西它跑得偏早。紐約的伺服器在本地 09:00——世界時鐘讀到 14:00Z——時,一個 13:59Z 產出的 OCSP 回應在天真的時鐘眼裡落在五小時後的未來,遠超過五分鐘級的偏移容許值,例如 HotPDF 預設的 OCSPAllowedClockSkewMinutes,於是被當成尚未生效而拒收。在上海符號反過來:本地 22:00 即 14:00Z 時,notAfter 為 20:00Z 的簽章憑證看起來已經過期,提前了八小時。這兩種失敗都不會出現在設成 UTC 的建置代理上——多數託管 CI runner 的預設

HotPDF VerifyLoadedSignatureWithTrust 在 Delphi 中以 UTC 評估 THPDFCertificateValidationOptions.ValidationTime,避開本地 Now 對 OCSP producedAt 與憑證 notAfter 時間造成的紐約與上海時鐘錯誤
拿本地的 Delphi Now 與 UTC 有效期時間相比,會在格林威治以西拒收新鮮的 OCSP 回應、在以東讓憑證提前過期——這兩種失敗在 UTC 建置代理上永遠看不到

HotPDF 把 THPDFCertificateValidationOptions.ValidationTime 當成 UTC。留著預設的 0,評估時間就成了當前 UTC 時刻,除非受信任的時間戳供應了簽章時間(下文再談),實際使用的時刻則回傳在 Info.CertificateValidation.EvaluationTime。VerifyLoadedSignatureWithTrust 在這一切之前先跑svValid 背後的位元組範圍驗證,然後才建路徑。您自己釘選歷史時刻時——例如稽核人員問季末那一刻發票簽章是否有效——換算是您的責任,System.DateUtils 的 TTimeZone.Local.ToUniversalTime 幹的就是這件事。請在時間戳進入您系統的地方套用它,而不是在驗證呼叫處:有日光節約時制的時區,每年秋天有一個本地小時會重複出現、春天又有一個從不存在,所以存本地時間戳的資料庫欄位無法讓每個時刻完整往返

uses
  System.SysUtils, System.DateUtils, System.IOUtils,
  HPDFDoc, HPDFSignVerify;

var
  Pdf: THotPDF;
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  ClosingLocal: TDateTime;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoices-2026-q1.pdf');
    Options := THPDFCertificateValidationOptions.Default;
    SetLength(Options.TrustAnchors, 1);
    Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
    Options.RevocationMode := rvmRequire;
    // 季末,按 ERP 記錄的樣子,在伺服器的本地時區
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // 憑證、CRL 與 OCSP 回應帶的都是 UTC 時間
    Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
    Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
    Writeln('Evaluated at (UTC): ',
      DateTimeToStr(Info.CertificateValidation.EvaluationTime));
    case Status of
      cvsTrusted:
        Writeln('Trusted via ',
          string(Info.CertificateValidation.TrustAnchorSubjectDN));
      cvsRevoked:
        Writeln('Revoked at (UTC): ',
          DateTimeToStr(Info.CertificateValidation.RevokedAt));
    else
      Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

一張 CRL 實際涵蓋哪些憑證?

CRL 只為它範圍內的憑證說話,範圍聲明在 RFC 5280 §5.2.5 的 Issuing Distribution Point 延伸裡。把撤銷資料分區的 CA 會發行多張 CRL,全部用同一把金鑰簽、全部新鮮,而憑證不在其中一張裡,不代表任何事——除非那張 CRL 聲明涵蓋它。收窄範圍的欄位有這些:

  • distributionPoint:CRL 只涵蓋其 CRL Distribution Points 延伸指名這個點的憑證
  • onlyContainsUserCerts 與 onlyContainsCACerts:只含終端實體憑證,或只含 CA 憑證
  • onlySomeReasons:只針對部分撤銷原因代碼的撤銷,例如 keyCompromise
  • onlyContainsAttributeCerts:CRL 談的是屬性憑證,對公開金鑰憑證隻字未提
  • indirectCRL:條目可能屬於 CRL 簽署者以外的發行者

天真做法是:驗 CRL 簽章、查 thisUpdate 與 nextUpdate、搜序號,序號不在就判憑證無恙。最後那一步只有在 RFC 5280 §6.3.3 (b)(2) 的範圍測試之後才成立。分區 7 的 CRL 永遠不會列出分區 3 的憑證;限於 CA 憑證的 CRL 永遠不會列出終端實體簽署者。兩者對一張已撤銷的憑證都答「找不到」。HotPDF 只在以下條件齊備時才為憑證查詢基礎 CRL:CRL 既不限於屬性憑證、也不限於某些原因;其使用者或 CA 限制與憑證相符;它指名的每個 distribution point 都與憑證自身 CRL Distribution Points 延伸裡的某個 URI 相符(不分大小寫比較)——以相對於 CRL 發行者的名稱形式給出的點什麼都配不上。限於特定原因的 CRL 根本不去查,所以它列出的撤銷也不算數。§6.3.3 確實允許從原因分區拼出完整涵蓋、追蹤原因遮罩直到每個原因都有交代,但對文件簽章來說,一份 OCSP 回應或一張全範圍 CRL 是更簡單的證據:收集其中之一。範圍內再無證據時,撤銷狀態就是未知,在 rvmRequire 之下呼叫回傳 cvsRevocationUnknown

HotPDF 在 Delphi 中為 PDF 簽章撤銷檢查做的 CRL 範圍測試:Issuing Distribution Point 欄位決定是否查詢基礎 CRL,範圍內無證據時 rvmRequire 依 RFC 5280 回傳 cvsRevocationUnknown
在 CRL 裡搜序號之前,它的 Issuing Distribution Point 必須先涵蓋該憑證;否則條目缺席對撤銷與否證明不了任何事

收尾路徑的信任錨必須是 CA

RFC 5280 §6.1.1 把信任錨當成普通資訊交給路徑驗證演算法:一個發行者名稱與一把公開金鑰,演算法從不問錨自己的憑證能不能發行東西;§6.1.4 的 basicConstraints 測試跑在路徑的中間憑證上。錨來自精心維護的根 CA 儲存庫時這沒問題。文件工作流程打破了這個假設,因為人們會釘選終端實體憑證:從三家已知供應商收合約的採購系統,常常直接信任每家供應商的簽章憑證。釘選在單憑證路徑上沒問題——簽署者與錨是同一張憑證。危險的是兩張憑證的路徑。供應商的私鑰可以簽出一張主體任填的新憑證,比方說您財務長的名字,而把每個錨金鑰都當發行金鑰看待的驗證器,會從這份偽造品一路建出通往被釘選終端的乾淨路徑

路徑長過一張憑證時,HotPDF 把 RFC 5280 的發行者要求套到錨上。此時錨需要帶 cA=TRUE 的 basicConstraints(§4.2.1.9),且若帶 keyUsage 延伸,keyCertSign 位元必須設置(§4.2.1.3)。沒有 basicConstraints 延伸的根,只有在自我發行(主體名與發行者名相等)時才算發行者,這讓早於延伸時代的老版本 1 根憑證照樣可用。釘選照常運作:被釘選的終端實體憑證仍能驗證自己的簽章,而它簽出來的憑證回傳 cvsPathValidationFailed,Issue 字串說明發行這條路徑的錨不是憑證授權單位

HotPDF 在 Delphi 中的 RFC 5280 憑證路徑驗證:TrustAnchors 裡被釘選的終端實體憑證能驗證自己的簽章,但它發行的路徑在缺 basicConstraints cA=TRUE 與 keyCertSign 時以 cvsPathValidationFailed 失敗
釘選供應商的簽章憑證,對該供應商的簽章照樣有效;其金鑰簽出的任何憑證則遭拒,因為錨不是憑證授權單位

哪些時間戳有資格設定評估時間?

只有受信任的才有資格。嵌在 CMS 裡的 RFC 3161 權杖聲明簽章值在 genTime 那一刻已存在,驗證器就在那一刻評判簽署者憑證——簽章比憑證活得久的機制就在這裡。權杖本身也只是另一個 CMS 簽章。拿它自帶的憑證驗它,證明的只是內部一致,對它是誰產的隻字未提:任何拿著已過期或已撤銷簽章金鑰的人都能自營時間戳服務,用去年的 genTime 給新簽章蓋章,把它走回憑證的有效期內。RFC 3161 §2.3 要求 TSA 憑證帶 id-kp-timeStamping 擴充金鑰用途且只作此用途、標為 critical。HotPDF 只在權杖憑證對您的錨、以當前 UTC 時刻、為時間戳用途驗證通過之後,才採用 Info.Timestamp.GenerationTime 作為評估時間。文件時間戳(/SubFilter ETSI.RFC3161)是另一回事:權杖本身就是受驗的簽章,其 genTime 適用於它自己的路徑

權杖是用您自己的選項檢查的,所以 TSA 根必須與簽署者根同列在 TrustAnchors 清單裡,而在 rvmRequire 之下,TSA 鏈也得有自己的撤銷證據。缺一樣,評估就回退到當前時間。TSA 憑證的壽命是另一道限制:權杖在當前時刻受評判,那張憑證一旦過期,簽章時間的評估也跟著失效,歸檔會在那一刻之前加一個文件時間戳,這一步的說明在PAdES LTV 證據與 B-LTA 時間戳續期。手裡握著原始 CMS 位元組的程式碼,可以透過 HPDFCMSTimestampTimeIsTrusted(CMSDER, Options) 問同一個問題;ValidateLoadedSignatureBatch 則把它套到批次裡的每個簽章。下面的函式回報每個判定是按哪個時鐘做出的

// 預期:只放 SignerRoot 時,憑證已於 2025 年過期的簽章
// 會以當前 UTC 時刻評估,不會回傳 cvsTrusted
// 即使其時間戳權杖驗證通過;加上 TsaRoot 後
// 同一個呼叫改以 genTime 評估
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // TSA 鏈與簽署者對照同一份錨清單評判
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // 受信任的時間戳可以供應時間
  Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
  if Info.Timestamp.Kind <> tskSignature then
    Clock := 'no signature timestamp, current UTC time'
  else if (Info.Timestamp.Status = svValid) and
    (Info.CertificateValidation.EvaluationTime =
     Info.Timestamp.GenerationTime) then
    Clock := 'trusted timestamp genTime'
  else
    Clock := 'timestamp not trusted, current UTC time';
  Result := Format('%s at %s (UTC), status %d', [Clock,
    DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;

第一個信任決定之前的敵意輸入

有些驗證工作跑在攻擊者完全掌控的資料上,此時還沒有任何東西被信任。缺失的中間憑證透過 RFC 5280 §4.2.2.1 的 Authority Information Access caIssuers URL 抓取,而每張抓回來的中間憑證又會依序宣傳自己發行者的 URL,工作清單一邊走一邊變長。在 Delphi 裡這排除了 for 迴圈:for 敘述的終值在迴圈開始前求值一次,走訪途中附加的 URL 永遠不會被拜訪,缺兩級的鏈就此停在殘缺狀態。改用 while 迴圈、每一輪重讀 Count。在 HotPDF 裡,HPDFFetchAIAIntermediates 的 MaxFetch 限制一次走訪下載多少憑證;FetchMissingIntermediates 讓路徑驗證自行抓取時,MaxFetchedIntermediates 設同一個上限

Retry-After 提示是陌生人挑的另一個值。它的伺服器由一張未經驗證的憑證指名,可以在每個請求上要求您等上一個鐘頭。夾住它。THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS 為 HotPDF 的證據與 AIA 抓取設這個上限,預設 30,000 ms。下面的常式把兩條規則都用上,適合自己去爬 AIA 的團隊——比如為離線簽章層預熱中間憑證快取

uses
  System.SysUtils, System.Generics.Collections;

type
  // 您的傳輸層:下載一個 caIssuers 目標,回傳其收到的
  // 憑證所宣傳的 caIssuers URL
  TFetchIssuer = reference to function(const URL: string;
    out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;

function WalkCAIssuers(const SeedURLs: TArray<string>;
  const Fetch: TFetchIssuer; MaxFetch: Integer;
  MaxRetryAfterMS: Cardinal): Integer;
var
  Queue: TList<string>;
  Seen: TDictionary<string, Boolean>;
  NextURLs: TArray<string>;
  URL: string;
  I, Attempt: Integer;
  RetryAfter: Cardinal;
  Ok: Boolean;

  procedure Enqueue(const AURL: string);
  begin
    if not Seen.ContainsKey(LowerCase(AURL)) then
    begin
      Seen.Add(LowerCase(AURL), True);
      Queue.Add(AURL);
    end;
  end;

begin
  Result := 0;
  Queue := TList<string>.Create;
  Seen := TDictionary<string, Boolean>.Create;
  try
    for URL in SeedURLs do
      Enqueue(URL);
    I := 0;
    // for 迴圈會在進入時把 Queue.Count 釘死,永遠走不到
    // 下方抓回憑證後附加的 URL
    while (I < Queue.Count) and (Result < MaxFetch) do
    begin
      Ok := False;
      for Attempt := 1 to 3 do
      begin
        NextURLs := nil;
        RetryAfter := 0;
        Ok := Fetch(Queue[I], NextURLs, RetryAfter);
        if Ok or (Attempt = 3) then
          Break;
        // 延遲是伺服器想要的間隔,而這台伺服器是由一張
        // 未經驗證的憑證指名的
        if RetryAfter > MaxRetryAfterMS then
          RetryAfter := MaxRetryAfterMS;
        Sleep(RetryAfter);
      end;
      Inc(I);
      Inc(Result);
      if Ok then
        for URL in NextURLs do
          Enqueue(URL);
    end;
  finally
    Seen.Free;
    Queue.Free;
  end;
end;

CMS 簽章檢查本身在路徑驗證之前執行——為驗不過的簽章建路徑是白費功夫——而且它用的是嵌在簽章裡那張憑證的公開金鑰。這個順序把攻擊者挑選的金鑰長度放到了最昂貴算術執行的那個時刻,彼時什麼都還沒被信任。RSA 驗證成本隨模數長度與指數長度一起漲,所以一張模數巨大、指數同樣長的憑證,可以讓驗證上傳檔案的伺服器上一條執行緒在模冪運算裡忙個不停。HotPDF 拒收模數超過 16,384 位元的 RSA、拒收長過模數的指數,模數高於 3,072 位元時只接受不超過 64 位元的指數。真正的簽章金鑰落在 2,048 到 4,096 位元之間、e = 65537 是 17 位元指數,天花板碰不到它們;下限是另一條可設定的政策 AlgorithmPolicy.MinimumRSAKeyBits,預設 2,048

同一個簽章可以在其時間戳的 genTime 上是 cvsTrusted、在今天的日期上卻失敗。把 Info.CertificateValidation.EvaluationTime 與 TrustAnchorSubjectDN 記在HotPDF Delphi PDF component 回傳的每個判定旁邊,日後讀記錄的稽核人員就看得出這個答案站在哪個時鐘、哪個錨之上