技術記事

暗号化 PDF に残る平文の署名値:/Contents が読める理由

他のツールで署名した AES 暗号化の契約書を読み込むと、署名値がランダムバイトにデコードされたり、AES-GCM では署名辞書がまるごと復号できないことがあります。trailer の /ID と encryption dictionary の外では、ISO 32000-2 の §7.6.2 が平文のまま置いていくのは署名の /Contents だけで、PDFlibPas は読み書きの両方でこれを平文に保ち、/Reason と /M は暗号化したままです

ISO 32000-2 が暗号化の規則に加えた除外

ISO 32000-2 の §7.6.2 は、暗号化がファイル内のすべての文字列とストリームを覆うと述べたうえで、4 つの例外を置いています:

  • trailer の /ID エントリの値
  • /Encrypt 辞書の中の文字列
  • ストリームの中の文字列(ストリーム全体として暗号化される)
  • 署名辞書の /Contents の値である 16 進文字列すべて

最初の 3 つは昔からあります。ISO 32000-1 の §7.6.1 はこの 3 つしか列挙していないので、2008 年の原文を根拠に書かれた復号器には、署名辞書の中身を読み飛ばせという指示がどこにもありません。それでも署名ツールは値を平文で書いてきました。理由は機械的なものです。署名器は 16 進のプレースホルダーを確保し、ファイル全体を書き出し、プレースホルダーを除く全バイトをハッシュし、CMS を組み立てて、その 16 進を隙間に張り付けます。ダイジェストはディスク上のバイト、暗号文込みをそのまま覆います。署名値を暗号化しても守られるものは何もなく、そのうえ張り付け工程はファイルキーを知らされ、ハッシュより前にサイズが決まったプレースホルダーへ IV とパディングを収めろと要求されることになります。辞書の残りは通常の規則に従います:

署名辞書のエントリPDF 型暗号化して格納?
/Contents16 進文字列いいえ、ISO 32000-2 §7.6.2 の除外対象
/Reason、/Location、/ContactInfo、/Nameテキスト文字列はい
/M日付文字列はい
/ByteRange整数配列いいえ、整数は決して暗号化されない
/Type、/Filter、/SubFilter名前いいえ、名前は決して暗号化されない

この除外を見落としたリーダーは、素の DER に暗号を回します。出てくるものはアルゴリズム次第です。RC4 はストリーム暗号なので、出力は長さを保ったままノイズに変わります。CMS パーサーは先頭バイトが 0x30 でなくなったのを見て、不正形式の署名を報告します。AES-CBC は先頭 16 バイトを初期化ベクトル、残りを暗号文として扱うので、パディングエラーで終わるか、より短いゴミの塊が手元に残ります。ISO/TS 32003 が PDF 向けに定義する AES-GCM は最も手厳しいケースです。暗号化された文字列はそれぞれ固有の nonce と認証タグを運び、平文の値はタグ検査を通れません。1 つのタグ失敗を辞書全体の致命傷として扱う復号器は、署名値と一緒に /Reason と /M も失います

PDFlibPas による Delphi での暗号化 PDF 署名辞書。ISO 32000-2 §7.6.2 のもとで 16 進の /Contents は平文のまま残り、/Reason と /M は暗号化される。RC4、AES-CBC、AES-GCM それぞれの失敗モードも併記
暗号化 PDF でも署名の /Contents は読める DER のまま残り、/Reason と /M は暗号文になります。この DER を無理に復号しても、暗号ごとに失敗の仕方は違います

署名の /Contents をそれ以外の /Contents からどう見分けるか

キー名での一致は誰でも最初に試す手ですが、どちらの向きにも破綻します。注釈辞書にも /Contents エントリがあり——レビュアーが打ち込んだコメントを収めたテキスト文字列です——この文字列は暗号化されなければなりません。読み飛ばすと、機密ファイルの付箋がすべてパスワードなしで読める状態になります。/Type /Sig での一致は逆の向きに破綻します。ISO 32000-2 の Table 255 は署名辞書で /Type を省略可能(既定値は Sig)にし、/DocTimeStamp でのみ必須としているので、規格に忠実な作成者は外すことがあります。そこで PDFlibPas は、同じ辞書が /ByteRange 配列を持ち、そのうえで /Type が Sig か DocTimeStamp、あるいは /Type がなくて /Filter 名が隣にあるときに限り、/Contents 文字列を署名値として扱います。/Filter はすべての署名辞書で必須、/ByteRange は署名フィールドに属するすべての署名で必須です。注釈にはどちらもありません。ライブラリは保存時の暗号化でも読み込み時の復号でも同じ判定を走らせるので、読み込み後の /Contents には作成側が書いたバイトがそのまま入っています

PDFlibPas の Delphi での暗号化テスト。/ByteRange、/Type の Sig か DocTimeStamp、/Filter によって署名の /Contents と注釈の /Contents を判別してから、文字列の暗号化や復号を行う
注釈コメントは暗号化されたままになります。PDFlibPas が /Contents を署名値として扱うのは、/ByteRange と一致する /Type が隣にあるとき、/Type がなければ /Filter 名があるときだけだからです

PDFlibPas ではまず暗号化、その後に署名

署名は最後に。署名ダイジェストは格納バイトを覆うので、署名後にファイルを書き換える操作はすべて、その中の署名を壊します。署名済み文書に暗号化を施せば各文字列とストリームが再暗号化され、暗号化を外すのも同じ徹底ぶりで書き直します。上手くいく順序は、暗号化して保存し、それから暗号化ファイルに署名する、です。署名プロセスは増分更新を追記します。新しい文字列は既存のファイルキーで暗号化され、/Contents のプレースホルダーだけは平文に保たれます。署名プロセスでファイルを開くときは owner パスワードを使います。user パスワードで足りるのは、権限が ISO 32000-2 Table 22 のビット 9(署名フィールドと明示的に名指されたもの)を含むときだけです。Encrypt と署名プロセスはどの PDFlibPas エディションでも使えます

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // 印刷、フィールド入力(ビット 9 が署名フィールドを覆う)、アクセシビリティコピー、高解像度印刷
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6: ファイルキーを直接使い、/ID は関与しない
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // なければ不可視で作成される
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // 後から追加する署名タイムスタンプのためのプレースホルダー余裕
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // 常に 0 を返す
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 は AES-256 revision 6 を選びます。この選択で失敗モードが 2 つ消えます。RC4 と AES-128 の下では、Algorithm 1(ISO 32000-1 §7.6.2)がオブジェクト番号と世代番号からオブジェクトごとの鍵を導出し、Algorithm 2(ISO 32000-1 §7.6.3.3 のステップ e)はさらに最初の /ID 要素をファイルキーに混ぜます。署名の増分更新は AcroForm 辞書とページの /Annots 配列を書き換えるので、書き換わった各オブジェクトは元の世代番号を保ち、更新の trailer は同じ最初の /ID 要素を運ばなければなりません。世代を間違えればそのオブジェクトだけが復号できなくなり、/ID を間違えれば何も復号できません。revision 6 はファイルキーを直接使い(ISO 32000-2 §7.6.3.3 の Algorithm 1.A)、/ID を見ずに鍵を取得します(§7.6.4.3.3 の Algorithm 2.A)。PDFlibPas はどの強度でも書き換えオブジェクトの世代番号を保持し、鍵の導出には格納どおりの /ID を使います。ISO 32000-2 Table 15 が要求する直接配列の代わりに /ID 7 0 R を運ぶ不正形式ファイルでも同じこと、空の /ID [] はパスワード検査に失敗します。パスワードから鍵への経路は、生のファイルキーで暗号化 PDF を開くの記事でより深く追っています

結果の確認はテキストエディタで足ります。署名辞書は、それを追加した増分更新の中に非圧縮で鎮座しています。署名器が /ByteRange と /Contents をファイルオフセットで張り付けるからです。/ByteRange を検索すればそこへ着地します。/Contents の 16 進はたいてい 3082 で始まります。CMS SignedData SEQUENCE の DER ヘッダーです。そしてプレースホルダーが署名より大きかったぶん、後ろに 0 の長い列を引いて終わります。/Reason は、テキストが同じでも署名のたびに変わる暗号文です。AES が文字列ごとに新しい IV を引くためです。最近の qpdf での期待結果:qpdf --password=owner-secret --show-object=14 signed.pdf が復号済みの /Reason を、無傷の /Contents の隣に表示します。この確認には strength 4 のファイルを使いましょう。素のファイルでの期待される形(オフセットとバイト列は変わります):

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

タイムスタンプを張り付けるとどの署名が壊れるか

時刻を押された署名のあとで追加された署名すべてです。PAdES の署名タイムスタンプは RFC 3161 トークンで、署名者の unsigned 属性(RFC 5652 §5.3 の unsignedAttrs)として保存されるので、/Contents の CMS の中に住んでいます。これを追加するということは、あの 16 進をその場で書き換えるということです。暗号化ファイルでこの書き換えが成立するのは /Contents が平文だからです。再暗号化も新オブジェクトも要らず、トークンは署名時に確保された空間に収まればいい。署名本体が生き延びるのは、その /ByteRange が当該バイトを飛ばしているからです。後続の署名にこうした免除はありません。ISO 32000-2 の §12.8.1 は、各署名のバイト範囲を %PDF- ヘッダーから、その署名を追加した増分更新の終端まで走らせます。したがって 2 つ目の署名やドキュメントタイムスタンプは、最初の署名の /Contents を他のすべてと一緒にハッシュします。あの隙間は 1 つの署名だけのものです。2 人目の署名者が署名したあとで張り替えると、2 つ目の署名のダイジェストは合わなくなります。トークンの要求自体はDelphi からの PAdES 署名と検証の一部であり、この節が扱うのはどのバイトが動くかだけです

PDFlibPas は何かをハッシュする前に重なりを調べます。署名タイムスタンプのプロセスは文書内の他の署名すべての /ByteRange を読み、各区間を対象の /Contents の隙間と突き合わせます。重なりが 1 つでもあれば準備はプロセス結果 17 で止まり、出力ファイルは書かれません。このプロセスは Enterprise edition に属し、PDFlibEnterprise ユニットの TPDFlibEnterprise としてインスタンス化します。他のエディションでもメソッド自体は残っていて、0 を返して LastErrorCode を 900 にします。ログで 17 という数字には注意を。GetPAdESSignatureTimeStampProcessResult での 17 は後続の署名がこれを覆っていること、GetSignProcessResult での 17 は認証レベルかフィールドロックが署名を禁じていることを意味します。2 つは別々に対応付けましょう。下のヘルパーは前のリスティングの SignField と OwnerPwd を使います

PDFlibPas の Delphi での PAdES 署名タイムスタンプの順序。後から署名した Approver の /ByteRange が先の Author の /Contents 隙間を覆うと、GetPAdESSignatureTimeStampProcessResult は 17 を返し、ファイルは書かれない
Approver が署名した時点でそのバイト範囲は Author の /Contents 隙間を覆うので、PDFlibPas は後から来た Author のタイムスタンプを、後続の署名を壊す代わりにプロセス結果 17 で拒みます
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // ハッシュ対象は書き換え済み、TSA 交換が失敗:GetTimeStampError を参照
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // 期待値: 17。Approver のバイト範囲は step2.pdf の中で Author の
    // /Contents を覆うため、プロセスは拒否し restamped.pdf は書かれない
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

各署名に、次の署名者がファイルに触れる前にタイムスタンプを押しておきましょう。署名者ごとに TSA への往復が 1 回増える代わりに、すべての署名が自分専用の信頼された時刻を持ちます。すでに複数の署名が入った状態で届くファイルは話が別で、先の署名はもう署名タイムスタンプを取れません。代わりにドキュメントタイムスタンプを追記します。新しいリビジョンで /Type /DocTimeStamp、/SubFilter /ETSI.RFC3161 の署名を加えるもので、そのバイト範囲は先行するすべての署名を覆い、その時点で存在していたことを証明します。ApplyPAdESLongTermArchiveValidation は検証情報を添付したあとでこれを追加します。署名タイムスタンプの部屋は署名時に確保しましょう。最初のリスティングのとおり SetSignProcessReserveContentsBytes を使います。TSA の証明書チェーンを埋め込むトークンはあっという間に数 KB に達します。この余裕のないプレースホルダーでは、後の拡張が結果 13 で失敗します

署名ファイルの復号と検証器がハッシュするもの

暗号化ファイルの署名対象範囲は暗号文です。検証器は格納された暗号文のダイジェストと CMS の中のダイジェストを比べます。/ByteRange は整数、/Contents は平文で、完全性検査に鍵は要りません。鍵を要するのはフィールド名と /Reason のほうです。同じ事実が、署名済みファイルを復号して平文で保存すると中の署名がすべて無効になる理由も説明します。目に見えるピクセルは 1 つも変わっていないのに、です。記録用のコピーは暗号化・署名済みの元ファイルのまま残しましょう。cross-reference ストリームも決して暗号化されません(ISO 32000-2 §7.5.8.2)。xref ストリームで書かれた更新は、鍵が導出されるより前にオフセットが読める状態を保ちます。このレイアウトはDelphi でのオブジェクトストリームと cross-reference ストリームで解説しています。署名範囲が大きすぎて比較のためにメモリに読めないとき、PDFlibPas は完全性検査を invalid ではなく unknown と報告します。比較されなかったバイトが違うと示されたわけではないからです

暗号化ファイルの署名値は、別の署名かドキュメントタイムスタンプが覆うまで、署名後も書き換え可能なままです。タイムスタンプ工程をこの窓の中に置けば、最初のリスティングの「暗号化してから署名」の順序は、Delphi 用 PDF Library の複数署名者ファイルにもそのまま持ち込めます