2 GBのPDFを暗号化することは、ストリーミングの問題のように聞こえます。ファイルを開き、2ギガバイトをAES-256に通し、結果を書き込む。そのメンタルモデルは、パフォーマンスの予算全体を決定づける意味で間違っています。ISO 32000-1 §7.6 は、PDF暗号化の粒度を個々のオブジェクト単位に設定しています。すべてのストリームとすべての文字列は個別に暗号化され、それぞれが独自の初期化ベクトルとパディングを持ちます。50万個のオブジェクトを持つ2 GBのアーカイブのスキャンは、1回の長いパスではなく50万回の小さなCBC操作であり、その規模になると、各操作の周囲の固定コストが内部のAESの算術演算よりも重要になります
この記事は、その固定コストについてのものです。Delphiのコードが非常に大きなドキュメントにAES-256を適用する際に時間がどこに費やされるか、そしてそれをどう取り戻すかについてです。パスワード、権限フラグ、リビジョン5と6の互換性の呼び出しなどの設定側については、HotPDFでのAES-256暗号化の設定に関する姉妹記事を参照してください。ここでの重複はありません
1回のパスではなく、50万回のCBC操作
ファイルの骨格は平文のままです。相互参照テーブル、オブジェクト番号、辞書のキー、ページツリーなど、これらはどれも暗号化されません。これにより、リーダーはパスワードを検証する前にオブジェクトの場所を特定できます。標準が暗号化するのはコンテンツです。ページ記述、画像、フォント、添付ファイルなどのストリームデータに加え、メタデータ値や注釈テキストなどの文字列です。AES-256暗号化フィルターの下では、それぞれが単独で処理されます。新しく生成されたランダムな16バイトのIV(初期化ベクトル)、バイトに対するCBC、16バイト境界へのブロックパディングが行われ、IVは暗号文の前に平文で書き込まれます
これには2つの結果が伴います。第一に、暗号文は常に平文よりも長くなります。IVが16バイトを追加し、パディングがさらに1から16バイトを追加するため、100バイトの文字列はディスク上で128バイトを占め、空のストリームでも32バイトを生成します。出力バッファのサイズを入力の長さに合わせたり、読み取ったバイト数だけを書き戻したりするコードは、すべてのオブジェクトの最後のブロックで復号化に失敗するファイルを生成します。第二に、コストは単なるバイト数ではなく、オブジェクト数に比例します。スキャンされたアーカイブは少数の大きな画像ストリームにバイトが集中していますが、何十万もの短いストリームと小さな文字列を保持しており、そこではAESではなく、操作ごとのオーバーヘッドがコストとなります
AES-256の設計における唯一の救いは、キーの処理です。リビジョン4までのセキュリティハンドラは、ファイルキーとオブジェクト番号、世代番号を一緒にハッシュ化することでオブジェクトごとに異なるキーを派生させ、毎回新しいキースケジュールを強制していました。/V 5スキームはオブジェクトごとの派生を廃止しました。1つのランダムな256ビットのファイルキーが、ドキュメント内のすべてのオブジェクトを暗号化します。この事実が、以下のすべての最適化を可能にします。コストのかかる暗号化状態は、オブジェクトごとではなく、ファイルごとに1回構築すればよいのです
R6の /Encrypt 辞書:1回の遅いオープンと、低コストなオブジェクト
リビジョン6のドキュメントは、トレイラーの /Encrypt 辞書でそのスキームを宣言しており、重要なエントリは数行に収まります
/Filter /Standard
/V 5 /R 6 /Length 256
/CF << /StdCF << /CFM /AESV3 /Length 32 /AuthEvent /DocOpen >> >>
/StmF /StdCF /StrF /StdCF
/O ...48 bytes... /U ...48 bytes...
/OE ...32 bytes... /UE ...32 bytes...
/Perms ...16 bytes... /P -3904 /EncryptMetadata true
/V 5 は256ビットキーのアーキテクチャを選択し、/R 6 は強化されたISO 32000-2のハンドシェイクを選択します。/CF は名前付きの暗号化フィルターを定義し(/AESV3 は前置されたIVを持つCBCモードのAES-256を意味します)、/StmF と /StrF はそのフィルターをそれぞれストリームと文字列に割り当てます。/O、/U、/OE、/UE はパスワード検証とキーラッピングのデータを保持し、/Perms は悪意のあるエディタが /P をこっそり反転させないように、権限ビットのAES暗号化されたコピーを保持します
コスト構造は /OE と /UE に隠されています。そこからファイルキーをアンラップする際には、Algorithm 2.B が実行されます。これは、SHA-256、SHA-384、およびSHA-512のラウンドを連鎖させる(データに依存する停止規則を持ち、少なくとも64回)反復型のキー派生関数であり、パスワードの推測に高いコストがかかるように意図的に遅く構築されています。この代償は、ライターがファイルを生成するときと、リーダーがそれを開くときに1回ずつ、それぞれ数ミリ秒の単位で支払われます。50万のオブジェクトを持つファイルにおいて、KDFはノイズに過ぎず、保存が遅い場合でも Algorithm 2.B は容疑者ではありません。オブジェクトごとのループが原因です
キーハンドルの再利用とスクラッチバッファの再利用
素朴な実装は整理されたユーティリティ関数になります。つまり、WindowsのCNGプロバイダを開き、CBCを選択し、キーオブジェクトを生成し、1つのバッファを暗号化して、すべてを破棄する EncryptAes256Cbc ヘルパーです。これは正確で、単体テスト可能ですが、50万回のループの中では壊滅的です。Microsoftのドキュメントは BCryptOpenAlgorithmProvider を高コストとしてマークし、ハンドルのキャッシュを推奨しています。また、BCryptGenerateSymmetricKey は完全なAESキースケジュールを実行し、プロバイダの状態を割り当てます。これは、キーがドキュメント全体で決して変わらない場合には完全に無駄です
Delphi RTLにはbcryptインポートユニットが同梱されていないため、エントリポイントを直接宣言します。以下のクラスはすべての暗号化状態を1回構築し、定常状態での割り当てなしに任意の数のオブジェクトを暗号化します
uses
Winapi.Windows, System.SysUtils, System.Classes;
const
BCRYPT_AES_ALGORITHM = 'AES';
BCRYPT_CHAINING_MODE = 'ChainingMode';
BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
BCRYPT_OBJECT_LENGTH = 'ObjectLength';
BCRYPT_BLOCK_PADDING = $00000001;
BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;
type
NTSTATUS = Integer;
BCRYPT_HANDLE = Pointer;
function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
if Status <> 0 then
raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
[Api, Cardinal(Status)]);
end;
type
TPdfObjectEncryptor = class
private
FAlg: BCRYPT_HANDLE;
FKey: BCRYPT_HANDLE;
FKeyObject: TBytes; // CNG キーオブジェクトのワークスペース。一度だけ割り当てられる
FScratch: TBytes; // 暗号文のスクラッチ。一度拡張された後はそのまま保持される
public
constructor Create(const FileKey: TBytes);
destructor Destroy; override;
procedure EncryptObject(const Plain: TBytes; Dest: TStream);
end;
constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
Mode: string;
ObjLen, Got: ULONG;
begin
inherited Create;
if Length(FileKey) <> 32 then
raise Exception.Create('AES-256 file key must be 32 bytes');
CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
'BCryptOpenAlgorithmProvider');
Mode := BCRYPT_CHAIN_MODE_CBC;
CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
'BCryptSetProperty');
CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
SetLength(FKeyObject, ObjLen);
// AESキースケジュールはここで一度構築され、すべてのオブジェクトで再利用される
CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;
destructor TPdfObjectEncryptor.Destroy;
begin
if FKey <> nil then
BCryptDestroyKey(FKey);
if FAlg <> nil then
BCryptCloseAlgorithmProvider(FAlg, 0);
inherited;
end;
procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
IV, IVWork: array[0..15] of Byte;
Need, Written: ULONG;
Src: PByte;
begin
// オブジェクトごとの新しいランダムなIV。これはデータの前を平文で移動する
CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
'BCryptGenRandom');
Src := PByte(Plain); // 空の入力に対するnilは有効: パディングのみのブロック
// サイズクエリ: CBCパディングは常に1〜16バイトを追加するため、Need > Length(Plain) となる
IVWork := IV; // BCryptEncrypt は連鎖する際にIVバッファを進める
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');
if ULONG(Length(FScratch)) < Need then
SetLength(FScratch, Need); // 数回拡張された後、そのまま保持される
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// AESV3 のレイアウト: 16バイトのIVの後に、パディングされた暗号文が続く
Dest.WriteBuffer(IV[0], 16);
Dest.WriteBuffer(FScratch[0], Written);
end;
重要な詳細が3つあります。サイズクエリ(出力バッファをnilにした最初の BCryptEncrypt 呼び出し)は、パディングされた暗号文の長さを返し、入力の長さと等しくなることはありません。パディングは決定論的であるため、自分で ((Len div 16) + 1) * 16 を計算して呼び出し回数を半分にすることもできますが、クエリはドキュメント化された契約です。第二に、BCryptEncrypt は連鎖しながらIVバッファをインプレースで進めるため、各呼び出しに作業用のコピーが渡され、元のIVが出力に着地します。第三に、FScratch はファイル内の最大のオブジェクトまで成長するだけで、その後ループ内での割り当ては発生しません
ハンドル再利用の価値の測定
この演習を強いたファイルは、1.8 GBのスキャンされたローンのアーカイブでした。412,000個の暗号化されたオブジェクトが含まれ、平文の構造を差し引くと1,710 MBのペイロードを持っていました。同じマシン、同じファイル、NVMeストレージ、1スレッドでの結果です
- 呼び出しごとのセットアップ(ヘルパー内でプロバイダを開き、キーを生成): 暗号化フェーズ 71.3 秒 — 1,710 MB ÷ 71.3 秒 ≈ 24 MB/秒
- 状態の引き上げ(上記のクラス): 9.6 秒 — 1,710 MB ÷ 9.6 秒 ≈ 178 MB/秒
その差は412,000回の呼び出しで61.7秒、つまりプロバイダを開き、連鎖モードを設定し、決して変わらないキーのキースケジュールを再構築するために、呼び出しごとに約150マイクロ秒が費やされたことになります。そのどれも暗号化処理そのものではありませんでした。AES-NIを使用すると、大きなバッファのCBC暗号化は1コアで約1.4 GB/秒の速度で実行されるため、AESの算術演算自体は9.6秒のうちの約1.2秒を占めるにすぎません。残りの大部分は、オブジェクトごとに2回発生するユーザーモードでの BCryptEncrypt への移行と、オブジェクトごとのIVの生成です。IVをバッチ処理する(1回の BCryptGenRandom 呼び出しで4,096個を埋める)と、実行時間は8.9秒に短縮されました。そこを過ぎるとAPIのオブジェクトごとの限界(フロア)に達し、残るレバーは並列化です。/V 5のオブジェクトは共有ファイルキーの下で独立しているため、それぞれ1つのキーオブジェクトを持つ4つのワーカースレッドを使用すると、出力ライターがシリアル化のポイントになるまでにフェーズは3.1秒にまで短縮されました
完全な書き換えと増分保存
粒度は保存のコストも決定します。既存の平文ドキュメントに暗号化を追加すると、定義上すべてのオブジェクトが書き換えられます。すべてのストリームと文字列のコンテンツと長さが変わり、すべての相互参照オフセットが移動し、増分的なパスは存在しません。これを完全なシーケンシャルな書き換えとして予算に組み込み、ターゲットの上に名前が変更される一時ファイルに書き込んでください。そうしないと、暗号化の途中でクラッシュした場合、どんなパスワードでも開けない半端に暗号化されたファイルが残ってしまいます
逆方向は低コストです。ファイルが暗号化されると、増分更新は同じファイルキーで暗号化された新しいオブジェクトを追加し、元のすべてのバイトを手つかずのまま残します。2 GBの暗号化されたアーカイブに承認の注釈をスタンプするコストは、数キロバイトの追加出力であり、2 GBの書き換えではありません。パイプラインの必然的な結果として、ジョブの最後のステップとして一度だけ暗号化し、その後のタッチは増分保存に任せます。ファイルキーも回転させるパスワードのローテーションは再び完全な書き換えになります。そのようにスケジュールを立ててください
自分を騙さずにスループットを測定する
暗号化スループットの主張は、分子、分母、またはその両方で間違っている傾向があります。分子はペイロードのバイト数であるべきです。これは、圧縮後に実際にAESに通されたストリームと文字列の長さの合計であり、ライターが処理しながら合計できます。ファイルサイズはそれを誇張します。上記のアーカイブはディスク上では1.8 GBですが、暗号化処理に触れるのはそのうちの1,710 MBだけです。分母は暗号化フェーズ単独であるべきであり、System.Diagnosticsの TStopwatch で囲み、解析、デフレート、ディスクI/Oはその外側に置きます。それらを組み込むと、まったく同じ暗号化コードでも、単に圧縮率が悪いファイルでは数倍遅く測定されてしまいます。上記の数値が比較可能であるのは、まさに割り算の両辺が暗号化のみだからです
これらはどれも、あなたが所有するコードである必要はありません。HotPDFは、同じエンジニアリングをコンポーネントのプロパティ(ActivateProtection、CryptKeyLength、UseAES256R6)の背後に、インタラクティブなVCLアプリケーションに適した高度でラップしており、代入順序の落とし穴についてはHotPDFのAES-256に関する記事でカバーされています。無人パイプラインの場合、PDFlibPasは単一の EncryptFile 呼び出し(Strength 4)で既存のファイルにAES-256リビジョン6を適用し、後でディスクに何が書き込まれたかを確認します。このワークフローは、PDFlibPasの暗号化監査に関する記事で解説されています
ここで説明した暗号化パスは、DelphiおよびC++Builder向けの HotPDF Component と PDFlibPasライブラリ に同梱されています。どちらの製品ページにも完全な暗号化リファレンスが掲載されています