技術記事

RFC 5280 が PDF 署名のパス検証に求めるもの

署名済み請求書はバイト単位で検証を通る。署名者の証明書は先月失効しているのに、取得した CRL にはシリアルが載っていない。その CRL は別の区画を代弁しているのです。HotPDF のパス検証はこうしたケースを念頭に設計されています。RFC 5280 のスコープとアンカーの規則、UTC で動く評価時計、そして TSA チェーンが信頼されて初めて時刻を数える RFC 3161 タイムスタンプです

評価時計は UTC で動く

パス検証器が比較する時刻は、すべて UTC で格納されています。RFC 5280 の §4.1.2.5 は証明書の有効期間の日付をグリニッジ標準時の UTCTime か GeneralizedTime で符号化するよう要求し、CRL の thisUpdate と nextUpdate も同じ符号化を使い、RFC 6960 の OCSP レスポンスは producedAt、thisUpdate、nextUpdate を UTC の GeneralizedTime で運びます。Delphi の TDateTime はタイムゾーンを持たず、Now はローカルの壁時計を返します。if Now > NotAfter と書かれた検査は、コンパイラの警告なしに 2 つの時計を混ぜます。誤りは地理で分かれます。グリニッジの西では早く進みます。ローカル 09:00 のニューヨークのサーバー、世界時計が 14:00Z を示すとき、13:59Z に生成された OCSP レスポンスは素朴な時計より 5 時間未来に座り、HotPDF の既定 OCSPAllowedClockSkewMinutes のような 5 分の許容から遠く外れるので、まだ有効でないとして拒まれます。上海では符号が裏返ります。14:00Z にあたるローカル 22:00 で、notAfter が 20:00Z の署名証明書はもう失効して見えます。8 時間早いのです。どちらの失敗も UTC に設定されたビルドエージェント——大半のホステッド CI ランナーの既定——では姿を見せません

HotPDF の Delphi での VerifyLoadedSignatureWithTrust。THPDFCertificateValidationOptions.ValidationTime を UTC で評価し、ローカルの Now が OCSP の producedAt や証明書の notAfter に対して引き起こすニューヨークと上海の時計誤りを避ける
ローカルの Delphi Now を UTC の有効時刻と比べると、グリニッジの西では新しい OCSP レスポンスが拒まれ、東では証明書が早く失効します。UTC のビルドエージェントには決して見えない 2 つの失敗です

HotPDF は THPDFCertificateValidationOptions.ValidationTime を UTC として扱います。既定の 0 のままだと、評価時刻は現在の UTC 時刻になります。信頼できるタイムスタンプが署名時刻を供給する場合を除き(詳細は後述)、実際に使われた瞬間は Info.CertificateValidation.EvaluationTime で返ってきます。VerifyLoadedSignatureWithTrust はこのどれよりも先にsvValid の背後にあるバイト範囲検証を実行し、それからパスを組み立てます。歴史的な瞬間を自分で固定するとき、たとえば四半期締め時点で請求書の署名が有効だったかを auditor に問われたときは、変換はあなたの仕事です。System.DateUtils の TTimeZone.Local.ToUniversalTime がそれをやります。適用場所は検証呼び出しではなく、タイムスタンプがシステムに入ってくる地点にしましょう。サマータイムのあるゾーンでは、秋に 1 つのローカル時刻が繰り返され、春にもう 1 つは決して存在しません。ローカルタイムスタンプのデータベース列は、すべての瞬間を往復保存できないのです

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 に証明書が載っていなくても、その CRL が対象と主張しているのでなければ、何も意味しません。スコープを狭めるフィールドは次のとおりです:

  • distributionPoint:CRL Distribution Points 拡張がこの点を名指す証明書だけを対象にする
  • onlyContainsUserCerts と onlyContainsCACerts:末端(end-entity)証明書のみ、または CA 証明書のみ
  • onlySomeReasons:keyCompromise のような理由コードの一部集合だけの失効
  • onlyContainsAttributeCerts:CRL は属性証明書に関するもので、公開鍵証明書には触れない
  • indirectCRL:エントリが CRL 署名者以外の issuer に属してよい

素朴な照会は CRL 署名を検証し、thisUpdate と nextUpdate を確かめ、シリアル番号を探し、見つからなければ証明書を good と呼びます。この最後の一手が成立するのは、RFC 5280 §6.3.3 (b)(2) のスコープ試験の後だけです。区画 7 の CRL が区画 3 の証明書を載せることはありません。CA 証明書に限った CRL が末端の署名者を載せることもありません。失効済み証明書に対し、どちらも「見つからず」と答えます。HotPDF がある証明書のために base CRL を参照するのは、CRL が属性証明書にも一部の理由にも限定されておらず、user か CA の制限が証明書に合致し、名指す distribution point が証明書自身の CRL Distribution Points 拡張に載る URI のどれかと、大文字小文字を無視して一致するときだけです。CRL issuer からの相対名で与えられた点は何にも一致しません。理由で限定された CRL はそもそも参照しないので、そこに載る失効も数えません。§6.3.3 は理由区画から完全な被覆を組み立てることを許しており、理由マスクを追跡してすべての理由を説明するまで続ける道もありますが、文書署名には OCSP レスポンスか全スコープの CRL のほうが単純な証拠です。どちらか 1 つを集めましょう。スコープ内の証拠が残っていなければ失効状態は unknown になり、rvmRequire の下では呼び出しは cvsRevocationUnknown を返します

HotPDF の Delphi での PDF 署名失効確認の CRL スコープ試験。Issuing Distribution Point の各フィールドが base CRL を参照するかどうかを決め、スコープ内の証拠がなければ RFC 5280 のもとで rvmRequire は cvsRevocationUnknown を返す
CRL からシリアル番号を探す前に、その Issuing Distribution Point が証明書を覆っていなければなりません。さもないと、エントリが載っていないことには失効について何の証明力もありません

パスを閉じるトラストアンカーは CA でなければならない

RFC 5280 の §6.1.1 は、パス検証アルゴリズムにトラストアンカーをただの情報として手渡します。issuer 名と公開鍵です。アルゴリズムはアンカー自身の証明書が何かを発行できるかを決して問いません。§6.1.4 の basicConstraints 試験はパスの中間証明書に走ります。アンカーが厳選ストアの root CA ならそれで構いません。文書ワークフローはこの前提を壊します。人は末端証明書をピン留めするからです。3 つの既知のサプライヤーから契約書を受け取る調達システムは、各サプライヤーの署名証明書を直接信頼しがちです。ピン留めは 1 証明書のパスでは正しく働きます。署名者とアンカーが同じ証明書ですから。危険なのは 2 証明書のパスです。サプライヤーの秘密鍵は、任意の subject——たとえばあなたの CFO の名前——を載せた新しい証明書に署名できます。すべてのアンカー鍵を発行鍵として扱う検証器は、その偽造物からピン留めされた末端まで、筋のいいパスを組み上げてしまいます

HotPDF は、パスが 1 証明書より長いときはいつでも、RFC 5280 の issuer 要件をアンカーに適用します。アンカーには cA=TRUE の basicConstraints(§4.2.1.9)が必要になり、keyUsage 拡張を運んでいれば keyCertSign ビットが立っていなければなりません(§4.2.1.3)。basicConstraints 拡張を持たない root が issuer として数えられるのは、subject 名と issuer 名が等しい自己発行のときだけです。拡張が存在する前からある古いバージョン 1 の root が動き続けるのはこのためです。ピン留めは動き続けます。ピン留めされた末端証明書は自分の署名を今も検証し、その鍵が署名した証明書は cvsPathValidationFailed で返ってきます。Issue 文字列は、パスを発行したアンカーが certificate authority ではないと述べます

HotPDF の Delphi での RFC 5280 証明書パス検証。TrustAnchors に置いたピン留め末端証明書は自分の署名を検証するが、basicConstraints cA=TRUE と keyCertSign がなければその鍵が発行したパスは cvsPathValidationFailed で失敗する
サプライヤーの署名証明書のピン留めは、そのサプライヤーの署名に対しては今も機能します。その鍵が署名する証明書はどれも拒まれます。アンカーが certificate authority ではないからです

評価時刻を設定できるタイムスタンプはどれか

信頼されたものだけです。CMS に埋め込まれた RFC 3161 トークンは、署名値が genTime の時点で存在したと主張します。検証器はその瞬間に署名者証明書を判定します。署名が証明書を生き延びる仕組みです。トークンは同時に、もう 1 つの CMS 署名でもあります。付属の証明書に対して検証して確かめられるのは内部整合だけで、誰が作ったかは何も語りません。失効や期限切れの署名鍵を握る者なら誰でも、独自の時刻認証サービスを立て、去年の genTime で新しい署名に時刻を押し、証明書の有効期間内へ引き戻せます。RFC 3161 の §2.3 は、TSA 証明書が id-kp-timeStamping 拡張鍵用途を唯一の目的として、critical で運ぶことを要求します。HotPDF が Info.Timestamp.GenerationTime を評価時刻として採用するのは、トークン証明書があなたのアンカーに対して、現在の UTC 時刻で、時刻認証目的として検証を通った後だけです。ドキュメントタイムスタンプ、/SubFilter ETSI.RFC3161 は別のケースです。そこではトークンこそが検証対象の署名で、genTime は自分のパスに効きます

トークンはあなた自身のオプションで検査されるので、TSA の root は署名者の root と同じ TrustAnchors リストに座っていなければならず、rvmRequire の下では TSA チェーンも自分の失効証拠を要ります。どちらかを欠けば評価は現在時刻へ後退します。TSA 証明書の寿命がもう 1 つの限界です。トークンは現在時刻で判定されるので、その証明書が切れたら署名時刻での評価も一緒に消えます。アーカイブはその点より前にドキュメントタイムスタンプを加えます。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 から取得し、取得した各中間証明書は今度は自分の issuer の URL を広告できます。作業リストは歩いている最中に育つのです。Delphi ではこの用途に for ループは使えません。for 文の終値はループ開始前に 1 度だけ評価されます。歩行中に追加された URL は決して訪問されず、2 段欠けたチェーンは不完全なままです。代わりに while ループで毎回 Count を読み直しましょう。HotPDF では、HPDFFetchAIAIntermediates の MaxFetch が 1 回の歩行でダウンロードする証明書数を切り、FetchMissingIntermediates がパス検証に自前の取得を許すときは MaxFetchedIntermediates が同じ上限を設定します

Retry-After のヒントは、知らない誰かが選ぶもう 1 つの値です。そのサーバーを名指したのは検証されていない証明書で、リクエストごとに 1 時間の停止を求めてくることだってあります。上限を切りましょう。THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS が HotPDF の証拠取得と AIA 取得の天井を設定します。既定は 30,000 ms です。下のルーチンは、AIA を自前で這い回るチーム——オフラインの署名ティアの中間証明書キャッシュを予め温めるなど——のために、2 つの規則を両方適用します

uses
  System.SysUtils, System.Generics.Collections;

type
  // あなたのトランスポート:caIssuers の宛先を 1 つダウンロードし、
  // 受け取った証明書が広告する 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 検証のコストは剰余長と指数長の両方に比例して伸びます。巨大な剰余とそれに負けない長さの指数を持つ証明書は、アップロードファイルを検証するサーバーのスレッドを、modular exponentiation で忙しくさせ続けられます。HotPDF は 16,384 ビットを超える RSA 剰余を拒み、剰余より長い指数を拒み、3,072 ビットを超えるなら 64 ビットまでの指数しか受け付けません。実用の署名鍵は 2,048〜4,096 ビットのあいだに座り、e = 65537、17 ビットの指数です。だから天井がこれらに触れることはありません。床のほうは別の、設定可能なポリシーで、AlgorithmPolicy.MinimumRSAKeyBits の既定は 2,048 です

同じ署名が、タイムスタンプの genTime では cvsTrusted でありながら、今日の日付では失敗し得ます。HotPDF Delphi PDF component が返すすべての判定の隣に Info.CertificateValidation.EvaluationTime と TrustAnchorSubjectDN を記録しておけば、後から記録を読む auditor は、その答えがどの時計の、どのアンカーの上に立つかを確認できます