技術記事

署名済み AES-256 PDF が Delphi で遅い理由と HotPDF の修正

HotPDF は Delphi で署名済み、AES-256 暗号化の PDF を以前よりずっと速くロードします。ロード経路から 3 つの別々のコストが取り除かれたからです。ソースストリームのバッファなしトークンパース、復号中の ISO 32000-2 Algorithm 2.A パスワードチェックの 2 度目の実行、R6 強化ハッシュの中の遅い AES-128-CBC 内側ループです。Delphi ターゲットでは、R6 の修正だけで文書ロードは約 66 ms から 16 ms になりました

どれも、ほとんどのテストスイートが使う小さなテストファイルには姿を見せません。1〜2 KB の未署名・非暗号化サンプルは、パーサが何をしようが数百分の一秒で開きます。厄介が始まるのは、顧客が実際に送ってくる文書です。埋め込み CMS 署名付きの契約書、約 35 KB、revision 6 のパスワードで保護。この組み合わせは高価なコード経路をすべて一斉にオンにし、それぞれに「必要以上に遅い理由」がありました。本記事は見つかった順にそれらをたどり、署名検証結果のメモ化を始めたときに浮上したキャッシュキーの過ちも扱います

34 KB の署名済み PDF が 170 ms かかった理由

34 KB の署名済み PDF のロードに 170 ms かかったのは、パーサが素の THandleStream を直接読んでいて、トークンのバイト 1 個ごとに専用の ReadFile システムコールになっていたからです。PDF のトークナイザはデリミタを探す間 1 バイトずつ前進するため、バッファなしのハンドルストリームは安価なメモリ内走査を何千ものカーネル遷移に変えます

細部がこのバグの生き延び方を説明します。THotPDF.LoadFromFile は長い間、ファイルソースを内部読み取りバッファで包んでおり、AutoDecrypt のプローブも同じ理由で、56 MB ファイルのサイズを 1.4 s かけて再問い合わせした後にすでに修正済みでした。ところが署名済み・暗号化文書は別の経路を通ります。遅延オブジェクト読み取りと署名検査が、呼び出し元が自分のストリームを解放した後でも、常に同じ不変リビジョンを見るように、THotPDF.LoadFromStream はソースのスナップショットを取り 2 度目のパースをします。その 2 度目のパースは、認証済みリビジョンに使われる reuse ロードとともに、バッファなしでスナップショットのハンドルを読んでいました。どちらも今は、ファイル経路が使うのと同じ内部バッファ付きリーダーを通ります

Delphi における HotPDF の署名済み AES-256 暗号化 PDF ロードパイプライン。スナップショットストリーム、バッファ付き構造パース、Algorithm 2.A の事前スキャン、回復済み鍵を再利用する 1 回の復号、ネイティブ AES-128-CBC を呼ぶ R6 強化ハッシュで、ロードを 66 ms から 16 ms に短縮
3 つの修正が 1 つの経路に積み重なります。バッファリングが何千ものシステムコールをなくし、事前スキャンした鍵がスコープフラグの下で再利用され、R6 の内側ループがネイティブ CBC バックエンドへ届きます

そのバッファは内部にあるので、自分で作ることはありません。制御できるのは LoadFromStream に渡すストリームで、HotPDF は最初の構造パスでそのストリームをそのまま読みます。データがすでにメモリにあれば TMemoryStream を渡します。ディスク上なら LoadFromFile を呼ぶか、Delphi 10.1 Berlin 以降なら素の TFileStream ではなく TBufferedFileStream をローダーに渡します

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // トークンごとの読みではなく一括読み 1 回
    Mem.Position := 0;
    // LoadFromStream はページ数を返す。文書がロードできなければ 0
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

ロード直後にメモリストリームを解放するのは、署名済み・暗号化入力では安全です。コンポーネントがすでにバイトの独自スナップショットを握っているからです。ただし、後で署名を検証するつもりなら、元のファイルやバッファは残しておいてください。検証は復号されたオブジェクトモデルではなく、ファイルの生の /ByteRange セグメントをハッシュするためです

ロードと検証を別フェーズとして測る

ロード時間と署名検証時間は別々に測るべきです。失敗の仕方が違い、直す場所も違うからです。ロードフェーズはパース、パスワードチェック、復号をカバーします。検証フェーズは署名済みバイト範囲を再ハッシュし CMS 署名を検査します。1 つの数字に折り込むと、どちらかのリグレッションがもう一方の中に隠れます

System.Diagnostics の RTL TStopwatch で足ります。下のハーネスは THotPDF.LoadFromFile のパスワードオーバーロードでロードし、それから VerifyLoadedSignature の TStream オーバーロードで最初の署名を検証します。暗号化ファイルにはストリームオーバーロードが正しい選択です。パスワードロード経路は、引数なしのオーバーロードが開き直せるようにソースファイル名を保持しないためです

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

数回走らせ、最初の 1 回は無視してください。最初の呼び出しは AES テーブル構築とセルフテストのような 1 回きりのセットアップコストを払い、ディスクキャッシュも最初の読みを歪めます。見るべきは、実在の顧客ファイルの定常状態の数字で、同じ文書の未署名・非暗号化コピーと対比して測ります

AES-256 パスワードが 2 回導出されていた理由

HotPDF が AES-256 のファイル鍵を 2 回導出していたのは、同じロードの 2 つの段階がそれぞれ自前で完全なパスワードチェックを走らせていたからです。構造パースの前に、RecoverReloadKeysFromStreamRanges が生バイトを事前スキャンし、フィルタのデコード中に暗号化オブジェクトストリームコンテナを復号できるようにします。この事前スキャンはすでに ISO 32000-2 §7.6.4.3.3 の Algorithm 2.A チェックを実行し、ユーザーとオーナーのソルトに対してパスワードを検証し、ファイル暗号化鍵を回復しています。その後 DecryptLoadedDocument がその結果を無視して導出全体をやり直していたのです

revision 4 以前なら重複は MD5 ループ 1 個分程度の安いものでした。revision 6 では、Algorithm 2.A は Algorithm 2.B の強化ハッシュを呼ぶため、重複はファイルを開く最も高価な部分を 2 倍にしました。回復済み鍵の再利用は単純です。安全に再利用する方が難しく、自分のパスワードチェックより長生きするキャッシュ鍵は認証バイパスに変わります

修正はスコープフラグで再利用を制限します。フラグは LoadFromFile と LoadFromStream のパスワードオーバーロード内の、事前スキャンから decrypt 呼び出しまでの間だけ true で、finally ブロックでクリアされます。フラグが立つのは、事前スキャンが実際にパスワードを検証したときだけです。素のロードの後で直接 DecryptLoadedDocument を呼ぶと、常に完全な導出が走ります。そのパスワードは事前スキャンされていないからです。この制限がなければ、以前の成功したオープンから残った古い reload 鍵が、間違ったパスワードを渡した後の呼び出し元のために文書を静かに復号しかねません。遅いロードよりはるかに悪いバグです

R6 強化ハッシュはどこに時間を使うのか

R6 強化ハッシュはほぼすべての時間を AES-128-CBC 暗号化に使います。ISO 32000-2 §7.6.4.3.4 の Algorithm 2.B はラウンドを少なくとも 64 回繰り返します。各ラウンドは、パスワードと現在のハッシュ、オーナー側ではさらに /U 文字列の 64 コピーのバッファを組み立て、ハッシュの先頭 16 バイトを鍵、次の 16 バイトを IV としてそのバッファを AES-128-CBC で暗号化し、暗号文の先頭 16 バイトを数として 3 で割った余りから次のハッシュに SHA-256、SHA-384、SHA-512 を選びます。ハッシュは短い。暗号化されるバッファはラウンドごとに数 KB です

Delphi における HotPDF R6 Algorithm 2.B 強化ハッシュループの図解。少なくとも 64 ラウンドのそれぞれが、パスワードとハッシュ、/U 文字列の 64 コピーのバッファを組み立て、ハッシュを鍵と IV として AES-128-CBC で暗号化し、暗号文の先頭バイトから SHA-256、SHA-384、SHA-512 を選ぶ
R6 のコストはダイジェストではなく AES-128-CBC のバッファ暗号化にあります。だからこそ PDF20HashDanceR6 内のネイティブ CBC ルーチンが文書ロード全体を約 66 ms から 16 ms に落とせるのです

このループの HotPDF 実装 PDF20HashDanceR6 は、以前はバイト指向のリファレンス AES を通じてバッファを 1 ブロックずつ暗号化していました。今はまずネイティブのパディングなし CBC ルーチンを試し、Delphi ターゲットではリンクされた C の AES バックエンドへ届きます。この変更で文書ロード全体が約 66 ms から 16 ms に落ちました。ネイティブルーチンが使えないか、一致しない長さを返した場合は、コードは歴史的なブロックループへフォールバックし、そのループは今度は、既存の S-box から T テーブルを生成するテーブル駆動 AES-128 ブロックを使えます。暗号はどの経路でも同じなので、導出された鍵はどの経路が計算してもバイト単位で同一です

本記事の主題は速度です。Algorithm 2.B の終了規則を正しく実装することは別の正しさの問題で、そこでの off-by-one は正しいパスワードを時々だけ拒否します。R5 と R6 の設定面はHotPDF で AES-256 PDF 暗号化を設定するガイドが扱います

速い AES ブロックが嘘をつく 4 つのやり方

テーブル駆動の AES ブロックは誤った暗号文を生みながら自分のセルフテストを通れます。下の 4 つの罠はすべて高速経路の構築中に出てきて、うち 2 つは最初に設計されたセルフテストをすり抜けました

  • セルフテストの再入。準備完了ルーチンはセルフテストの実行前に初期化済みフラグを立てるため、テスト中の再入呼び出しはリファレンス経路を取ります。そのためテストの最初の版はリファレンス実装と自分自身を比較していました。修正は、セルフテストが直接呼ぶガードなしの生高速ブロックで、テストされるコードが実際にテーブル経路になるようにします
  • 入れ替わったテーブルレイアウト。4 つのラウンドテーブルのうち 2 つ(Te1 と Te3)のバイト順を交換すると、再入セルフテストには気付けない誤った暗号文が生まれます。捕まえたのは独立のプローブプログラムで、鍵 000102…0f、平文 00112233…ff に対する FIPS-197 附属書 C.1 の暗号文 69c4e0d86a7b0430d8cdb78070b4c55a を出力するものでした。セルフテストは今、そのベクトルとリファレンスブロックとのバイト一致の両方を要求してから高速経路を信頼します
  • FPC の範囲とオーバーフローチェック。-Cr と -Co の下の Free Pascal では、Delphi 流の明示 Byte() 変換とバイト幅のシフト演算が範囲例外やオーバーフロー例外を上げます。テーブルインデックスと GF(2^8) の倍化は今、ネイティブ整数幅で計算し and $FF でマスクします
  • FPC インライナの誤コンパイル。PByte パラメータを取る小さなビッグエンディアンのロード・ストアヘルパーは、FPC の下で inline を付けるとアクセス違反にコンパイルされました。inline を外せば直り、呼び出しのコストはテーブル参照の隣では無視できます

最終的な配線の背景にも同じ経験があります。ライブラリ内のすべての AES-128 ブロックを高速テーブルに通すと、ある公開候補のロード中に決定論的な FPC アクセス違反が出たため、その変更は取り消されました。出荷された設計は保守的です。まずネイティブ CBC、R6 ループ内の明示的な高速テーブル分岐、それ以外はすべてリファレンスブロック。一般教訓は、複数のコンパイラでビルドされなければならない新しい暗号コードには、ターゲットコンパイラごとに独立した既知解答プローブが要る、ということです。DLL レベルのテスト失敗では、間違ったテーブルなのかクラッシュなのか判別できません。Free Pascal 側の詳細はFree Pascal 下の HotPDF Deflate と AES バックエンドに関するノートを参照してください

間違った答えをキャッシュせずに署名検証をキャッシュする方法

署名検証キャッシュが安全なのは、判定を変え得るすべての入力、署名済みバイト範囲の外に住む入力まで含めて、キーがカバーしているときだけです。HotPDF の headless 文書層は、CMS バイト、/Contents ギャップ内の署名辞書のバイト、カバー範囲の内容、ポリシーフィンガープリント、検証予算、ソース全体サイズ、1 分のタイムバケットをハッシュして CMS 検証結果をメモ化します。これらの入力のうち 3 つは、バグが欠落を示した後で足されたものです

Delphi における HotPDF 署名検証キャッシュキーの入力。CMS バイト、/Contents ギャップ内の署名辞書、カバー範囲の内容、ポリシーフィンガープリント、予算、ソース全体サイズ、1 分のタイムバケットが、CMS 判定のメモ化前に一緒にハッシュされる
判定を再利用できるのは、それを変え得るすべての入力がキーに入っているときだけです。ソースサイズが重要なのは、ベースリビジョンとそのインクリメンタル更新が同一のカバー範囲を共有し得るためです

信頼する証明書ファイルはパスではなく内容でキー化されます。操作層は同じ PEM 素材をコマンドごとに新しい一時ファイル名へ書くため、パスベースのキーは決して一致せずキャッシュは決して当たりませんでした。ソース全体サイズがキーの一部でなければならないのは、報告される UnsignedTrailingBytes がそれに依存するからです。ベースリビジョンと後のインクリメンタル更新は、末尾の長さが違うながら、カバー内容がまったく同じであることがあり、範囲だけにキー化されたキャッシュはベースリビジョンの判定を更新後のファイルに渡してしまいます。それらの末尾バイトの意味の背景はDelphi で PDF 電子署名を検証する HotPDF ガイドにあります

3 つ目のバグは設計ミスではなく Pascal 言語の罠でした。ハッシュルーチンは型なし const バッファを取り、動的配列変数を型なしパラメータに渡すと、配列データではなく配列参照のアドレスが渡ります

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // 誤り:配列参照と、スタック上でその後ろにあるものを読む。
  // そこからの 16 KB の読みはスタックページ外へ走りアクセス違反を
  // 起こし、Delphi と FPC は違う場所で落ちる
  Writeln(Checksum(CMS, Length(CMS)));

  // 正しい:最初の要素を渡す。空配列はガードする
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

キャッシュのロックは Windows の InitializeCriticalSection API ではなく SyncObjs.TCriticalSection です。同じユニットが Linux の FPC でもビルドされ、その API は存在しないためです

意図的に遅いままのもの

R6 の鍵導出そのものはロード間でキャッシュされず、それは意図的です。外部バイトと明示的に渡されたパスワードは常に本物の認証経路を通るため、R6 文書のパスワードロードはすべて最低 1 回の Algorithm 2.B ハッシュを払い、オーナーパスワードを試すときはユーザー側とオーナー側の両方のハッシュを払います。上述のスコープ付き再利用がなくすのは 1 回のロード内の重複だけで、それ以上ではありません

  • 66 ms から 16 ms への数字は、ネイティブ AES バックエンドがリンクされた Delphi ターゲットに当てはまります。FPC ビルドはテーブル駆動ブロックを取り、Windows の FPC コード生成は同じアサーションの下で Delphi より 2〜4 倍遅く走りました
  • 検証キャッシュは 1 分のタイムバケットに束縛されるため、短い時間枠内の同じバイトの繰り返しチェックは安価で、キャッシュされた判定が計算された分を超えて生き延びることはありません
  • マージリビジョン権限キャッシュは実装され、デバッグされ、そして削除されました。FPC の差を閉じず、Delphi のセキュリティコールバックを測定可能なほど遅くしました。キャッシュを残す前に測ることへの良い注意喚起です
  • 署名検証には元の暗号化ファイルのバイトが要るため、ソースを手元に置いてください。速い復号はこの要件を何も取り除きません

FAQ

事前スキャン鍵の再利用はパスワードチェックを弱めるか

いいえ。HotPDF は、完全な Algorithm 2.A チェックでパスワードを検証した同じパスワードオーバーロード呼び出しの中でのみ回復済みファイル鍵を再利用し、スコープフラグは finally ブロックでクリアされます。単独の DecryptLoadedDocument 呼び出しは常に完全な導出を実行するため、以前のオープンから残った鍵が間違ったパスワードで借り出されることはありません

ロード間で R6 ハッシュ結果をキャッシュしない理由

ロード間でハッシュをキャッシュするというのは、開こうとしているファイルに対してパスワードを検証せずに受け入れることです。HotPDF はすべての外部バイトソースとすべての明示的パスワードを、自前で認証しなければならない信頼できない入力として扱うため、測定可能な高速化は Algorithm 2.B を速くすることから来たのであって、省略からではありません

LoadFromStream に TFileStream を渡すべきか

ディスク上のファイルには LoadFromFile を選んでください。内部でバッファリング済みです。ストリームオーバーロードが必要なときは TMemoryStream か TBufferedFileStream を渡します。HotPDF は最初の構造パスで渡されたストリームを直接読み、素のハンドルストリームは小さな読みごとにシステムコールを払うためです

パスワード保護ロード、AES-256 R6 暗号化、CMS 署名検証、ロード済み文書 API の残りは、Delphi と C++Builder 向けの 1 つのネイティブ VCL ライブラリとしてまとめて出荷されます。機能詳細と対応 IDE バージョンは HotPDF Component 製品ページにあります