技術記事

Delphiで暗号化されたPDFを静かにパッチすることはできない

すでにAES-256暗号化を運んでいる請求書PDFを取り、DelphiおよびC++Builder向けPDFiumコンポーネント(PDFiumPas)に、完全な書き換えではなく増分更新を通じてそれをアーカイブ保存用にPDF/Aとしてスタンプするか、PAdESで署名するよう求めてみてほしい。このライブラリは、暗号化されたバイトを直接パッチすることではそこにたどり着かない:その6つの適合性マーカー注入関数は既存の/Encryptエントリを検出し、ソースをバイト単位で変更せずに通過させる。そしてそのPAdES署名機能は、どんなバリデータも受け入れない署名を発行するのではなく例外を発生させる

これは、自分が作成していないPDFに隠れたリスクがないか監査するという、それ自体が読み取り専用の演習であるものとは異なる問題である。本稿は同じ信頼境界の書き込み側について:自分のコードが、すでに他人のパスワードの背後にロックされたバイトを持つファイルに対して、事後に何かを追加しようとする瞬間に何を許されているかについてである

暗号化されたPDFを更新するとき、ISO 32000-1は何を要求するのか

ISO 32000-1 §7.5.6は、増分更新のトレーラーが/Prevを除く以前のトレーラーのすべてのエントリを繰り返すことを要求しており、表15は/Encryptをトレーラーが運べるエントリの中に挙げている。新しいトレーラーからそれを落としても、準拠したリーダーにはその省略を疑う理由がない:最新のトレーラーが権威を持つため、そこに/Encryptを見つけられなかったリーダーはファイル全体が暗号化されていないと判断し、より古い依然として暗号化されている本体をプレーンなバイトとして解析しようとする。新しいトレーラーに/Encryptを保ちながら、更新自身のオブジェクトを平文として書くと、失敗はただ1段階後にずれるだけだ:リーダーは正しく暗号化を検出し、それが触れるすべてのオブジェクトをファイルの暗号を通じて処理する。そもそも一度も暗号化されていなかった新しいオブジェクトも含めて、そして復号がそれに触れる前は完璧に読めていたコンテンツに対してノイズを受け取ることになる。どちらの間違いも、準拠したリーダーがそれを開くまさにその瞬間まで、バイトレベルでは通常の整形式の増分更新のように見えるファイルを生み出す

6つのマーカー注入関数、1つのv2.14.2暗号化ゲート

PDFiumPasは、ラベル付けできる各ISO PDFサブセットにつき1つずつ、6つのバイトレベルのマーカー注入関数を同梱している:PDF/A(ISO 19005)、PDF/X(ISO 15930)、PDF/UA(ISO 14289-1)、PDF/E-1(ISO 24517-1)、PDF/R-1(ISO 23504-1)、PDF/VT-1(ISO 16612-2)である。それぞれがPDFium自身のFPDF_SaveAsCopyがすでに書いたバイトを受け取り、その上に2つ目のより小さな増分更新を重ねる:新しいXMPメタデータストリーム、それを指すカタログ辞書の編集、そして印刷向けのサブセットについてはOutputIntentとICCプロファイルである。v2.14.2の時点で、InjectPdfAMarkersInjectPdfXMarkersInjectPdfUaMarkersInjectPdfEMarkersInjectPdfRMarkersInjectPdfVTMarkersのすべてが最初にソースのトレーラーを読み、既存の/Encryptエントリを報告した場合、ソースを未変更のまま出力ストリームへコピーしすぐに戻る。XMPもOutputIntentもカタログの編集もない——呼び出し元は元のファイルをバイト単位で正確に受け取る

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf is byte-identical to the source: still encrypted,
    // no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
    // nothing was corrupted either
  finally
    Dst.Free;
    Src.Free;
  end;
end;

暗号化が許可されていることと、注入が安全であることは同じではない

PDF/E-1とPDF/R-1はどちらも、仕様のレベルではホスト文書が暗号化されることを明示的に許可している。これは、実際にディスク上で何が起きなければならないかを見るまでは免除のように読める。ISO 24517-1 §6.3はPDF/E-1に対して暗号化を許可し、ISO 23504-1 §6.2.3はヘッダーが%PDF-2.0を宣言している限りPDF/R-1に対してそれを許可する。どちらの条項も、バイトレベルの後処理器がその暗号化されたコンテナに平文オブジェクトを安全に追加できるかどうかについては何も語っておらず、それはできない、他のすべてのサブセットに適用されるのと同じ§7.5.6の理由による。これら2つのプロファイル向けのPDFiumPas自身の適合性バリデータ、ValidatePdfEComplianceValidatePdfRComplianceは、/Encryptの存在を意図的に欠陥としてフラグを立てることなく記録する。これは1バイトも書かない読み取り専用のバリデータにとっては正しい。それはまた、兄弟である注入関数には別個のガードが不要だと見過ごして仮定してしまいやすいパターンでもある。注入関数こそが、そのペアの中で実際に拒否しなければならない唯一の関数である

SaveAsPdfXは文書を静かに復号するのか

そうする、注入関数を直接呼ぶのではなく公開の便利なメソッドを経由する限りは。TPdf.SaveAsPdfASaveAsPdfXSaveAsPdfUaSaveAsPdfESaveAsPdfRSaveAsPdfVTのすべてが、そのバイトを一致する注入関数に渡す前に、SaveAs(Tmp, saRemoveSecurity)で現在の文書を一時ストリームへレンダリングする。saRemoveSecurityはPDFium自身のFPDF_REMOVE_SECURITYフラグにマップされる。そのため注入関数が受け取る一時コピーはそもそも一度も暗号化されておらず、注入関数の/Encryptガードが発動する理由が一切ない。出力はあなたのPDF/A、PDF/X、PDF/UA、PDF/E-1、PDF/R-1、あるいはPDF/VT-1のマーカーを運ぶが、それはもはやソースを開いたどんなパスワードによっても保護されていない

そのトレードオフは、下流の誰かがパスワードなしで「保護された」アーカイブコピーを開き、それが単純に機能することに気づくまで見えないままである。この修正は別のメソッド呼び出しではない;PDFiumPasにはsaRemoveSecurityと対になるsaAddSecurityは存在しない。なぜなら基礎となるPDFiumエンジンは、そもそも新しい暗号化を書くようには作られておらず、それを取り除くためだけに作られているからだ。両方のプロパティが1つのファイルにとって重要であるなら、暗号化は同じSaveAsPdfA呼び出しに折り込むのではなく、適合性マーカーの後に適用されるあなた自身が所有する別個のステップにしなければならない

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // needed to open the source at all
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
    // ran first inside SaveAsPdfA: the output opens without a password
  finally
    Pdf.Free;
  end;
end;

暗号化されたPDFをPAdESで署名するとどうなるのか

PDFiumPasはマーカー注入関数がそうするように静かに要求を落とすのではなく、完全に拒否する。TPdf.SignPadesSignPadesToStreamはどちらも内部のSignPadesBytesを経由し、ソースのトレーラーを解析した後最初に行うことは/Encryptのチェックである。そのエントリが存在する場合、それはこれ以上先に進む代わりに「SignPadesBytes: the source document is encrypted; remove encryption before signing」というメッセージを伴うEPadesCryptoを発生させる。長期検証のための証明書、OCSP応答、CRLを埋め込む関数であるInjectPadesDssMarkersも、独自のメッセージで同一の理由による同一のチェックを適用する:「InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material」

ここでの判断はマーカー注入関数の通過よりも厳格であり、それは意図的なものだ。静かな通過は、それをスキップしても、単にラベルが付いていないだけで開始時と同じ有効なPDFのままであるため、PDF/Aのスタンプには安全である。署名はそれほど静かには失敗できない:静かに一度も追加されなかった署名は、ブール型の結果しかチェックしない呼び出し元のコードにとって、正常に追加された署名と全く同じに見える。EPadesCryptoは通常のExceptionクラスを継承しているため、それを捕まえることは学ぶべき特別な制御フローの慣習ではなく普通の例外処理である

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

適合性スタンプ、署名、暗号化の順序を決める

実用的な修正は別のライブラリではなく順序である。まずPDF/A、PDF/X、PDF/UA、PDF/E-1、PDF/R-1、あるいはPDF/VT-1のマーカーを適用し、次にPAdES署名を追加し、それからようやくあなたのパイプラインの中で実際に暗号化を担当するステップ(専用のPDFライター、署名アプライアンス、あるいはあなた自身のAES実装であれ)を実行する。PDFiumPasの増分更新レイヤーは、その順序の中間に自然に収まり、それ以外は完成しているファイルに小さな標的を絞ったオブジェクトを追加する。そして暗号化はその末尾に属する。まさにそれが、この連鎖の中でPDFiumPas自身が実行も逆転もできない唯一の操作だからだ

これは、あらゆる増分更新が依存するトレーラーと相互参照データをPDFiumPasがどう読むかについては何も変えない。これは、xrefストリームが登場するとそれ自体微妙な話題になる;PDFのオブジェクト・xrefストリームの検証は、その同じトレーラー読み取り経路がPDF 1.5以降の圧縮された構造をどう扱うかを扱っている。そして文書が適合性スタンプよりも強力な何かの準備ができたら、DelphiでPAdES B-B署名でPDFに署名するが、本稿がまさに終わったその地点からSignPadesが引き継ぐ場所である

ここで説明したマーカー注入関数とSignPadesメソッドは、PDFiumがネイティブに提供するレンダリングと読み取り専用の検査とともに、DelphiおよびC++Builder向けPDFiumコンポーネントの一部として出荷される