技術記事

Delphiで検証するPDF MACリビジョン連鎖(ISO 32004)

HotPDFはISO/TS 32004のPDF MACを、ファイル単位ではなくリビジョン単位で検証します。THotPDF.ValidatePDFMACChainは連鎖のアンカーから前方向きにすべてのインクリメンタルアップデートを辿り、各MACを、そのリビジョン自身のstartxref%%EOFで終わる読み取り専用プレフィックスストリームに対して検証します。最新リビジョンで一つの有効なMACがあっても、その下のリビジョンについては何も証明しません

動機となったシナリオはこうです。AES-256で暗号化しPDF MACを付けたPDFを出荷します。誰かがhexエディタでファイルを開き、最初のMAC保護リビジョン内の1バイトを書き換え、それから自分の完全に有効なMACを載せた全く新しいリビジョンを追加します。すべてのビューアは文句なしにファイルを開き、現在のバイト範囲をアクティブトレーラーのMACに対してハッシュする素朴な検査器は成功を報告します。そのMACは本当に、それがカバーするバイトについて正しいからです。損害は二つ下のリビジョンに、誰も再検査しなかった領域にあります

有効なトップレベルMACがファイルの完全性を証明しない理由

PDF MACはプレフィックスをカバーするのであって、文書をカバーするのではないからです。インクリメンタルアップデートは形式の一級市民です。保存のたびに新しい本体、新しいクロスリファレンスセクション、新しいトレーラーが追加され、古いバイト列は元の場所にそのまま残ります。ISO/TS 32004はこのモデルに乗るため、各リビジョンは、その時点でのファイルを認証する独自の/AuthCode辞書を運び、最新のものだけを検証しても以前のリビジョンはすべて未検査のままです。そこでHotPDFは二つの質問を二つの呼び出しとして公開します。その違いがこの記事の要点です。ValidatePDFMACは「現在のリビジョンは本物か」に答え、THPDFPDFMACValidationInfoレコードを埋めます。ValidatePDFMACChainは「このファイルのMAC保護リビジョンはすべて本物か」に答え、リビジョンごとの配列と機械可読な失敗理由をTHPDFPDFMACChainValidationInfoに埋めます。冒頭の改変・再MAC済みファイルでは、最初の呼び出しはTrueを返し、二つ目はリビジョンインデックス1に対してFalseを返します

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // 失敗コードは pmcfRevisionBoundary、pmcfNoPDFMAC、
      // pmcfRequiredRevisionMissing、pmcfRevisionInvalid、
      // pmcfKDFSaltChanged、pmcfDigestDowngrade、pmcfPermissionDowngrade のいずれか
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

各MACは独自のプレフィックスストリーム上で検証され、最終ファイル長では決してない

この領域で最も高くつくバグは、古いリビジョンを再ハッシュするときに最終ファイルサイズを上限として使うことです。末尾のバイトが最新以外のすべてのダイジェストに折り込まれ、健全なファイルに対して改変を報告します。HotPDFはその代わり、各リビジョンについて、そのリビジョン自身のstartxref値に続く%%EOFで終わる有界の読み取り専用ストリームを再構成し、そこだけをハッシュします。境界の特定は見た目より面倒です。リテラルの%%EOFはコンテンツストリームや文字列の中にも現れ得るため、直前のstartxrefが検証対象セクションのクロスリファレンスオフセットと等しい数値に解析され、両者の間に空白以外の何もない場合にのみ候補は受理されます。リビジョンはその後、マーカーの後にちょうど一つの行末シーケンス(単一CR、単一LF、CRLF一組のいずれか)だけを取り込み、それ以上は取り込みません。この最後の規則は実務で効いてきます。二つのリビジョンの間に余分な空行を出力したライターが生んだバイト列は次のリビジョンに属し、後続の空白をすべて前のリビジョンに呑み込むと、両方のダイジェストが黙って変わるからです。セクションの列挙も同じ規律に従います。HotPDFはクロスリファレンスセクションを古いものから新しいものへちょうど一度だけ辿り、free、直接、オブジェクトストリームの各エントリを再生して後のセクションが前の状態を上書きします。アクティブxrefパーサーが適用する最初に見た方が勝つセマンティクスの逆です

HotPDFは各ISO 32004 PDF MACを、そのリビジョン自身のstartxrefとEOFマーカーで終わるプレフィックスストリームに対して検証する。リビジョン1内の1バイト改変は、最新MACが単独では有効でも連鎖を失敗させる
各リビジョンのMACは独自の有界プレフィックス上で再ハッシュされるため、リビジョン1を編集して新鮮なMAC付きリビジョンを追加してもValidatePDFMACは満足し、ValidatePDFMACChainはリビジョン1に着地する

連鎖はどこでアンカーを置き、何が壊すのか

有効な/AuthCodeを運ぶ最初のリビジョンがアンカーであり、FirstMACRevisionIndexが保護の開始位置を報告します。それ以前は構成上保護されておらず、それは正常です。それ以降はすべてMAC保護されていなければならず、MAC保護ファイルに一つの平文インクリメンタルアップデートを追加するとpmcfRequiredRevisionMissingと該当リビジョンインデックスで失敗します。隙間を許すと、攻撃者はもう一度保存するだけで保護を剥がせるからです。さらに三つの不変条件が連鎖を跨いで成立し、それぞれ固有の失敗コードを持ちます

  • pmcfKDFSaltChanged/KDFSaltはアンカー以降安定していなければなりません。回転するソルトは、鍛造者が自分で選んだパラメータの下で鍵を再導出することを許します
  • pmcfDigestDowngrade — ダイジェスト強度は直前のリビジョンではなく、最後に検証したMACと比較されます。SHA-384のModernプロファイルで始まった連鎖がSHA-256へ黙って続行することはありません
  • pmcfPermissionDowngrade — リビジョンは、以前のリビジョンが認証したPDF MAC要件を解除できません

心に刻むべき帰結は、過去のMACがアクティブトレーラーでなくなった後でも独立に検証されることです。冒頭の「古いリビジョンを編集して新鮮なMACを追加する」攻撃が生き残れないのはこのためです。最新MACは単独で合格し、ValidatePDFMACは満足しますが、連鎖はpmcfRevisionInvalidでリビジョン1に着地します

署名の順序:トレーラーキーが先、signatureDigestが最後

MACがCMS署名に添付される場合、書き込み順序は様式の問題ではなくなります。HotPDFは/AuthCode/KDFSalt、ISO 32004開発者拡張、/SigObjRefが、署名の/ByteRangeが計算されるに同じリビジョンへ書き込まれることを要求します。後から追加すればそれらのバイトは署名がカバーする範囲の外に落ちて、署名は検証するのにMAC束縛が未署名のファイルができます。二つのダイジェストは逆方向に走ります。一見循環に見えてそうではありません。PDF MACのsignatureDigestはCMSのSignerInfo.signature OCTET STRINGの生のコンテンツオクテットを束縛します。CMS DER全体でも署名付き属性でもありません。そのため生の署名値が存在した後に構成され、id-attr-pdfMacDataの未署名属性として注入されます。/Contentsは署名のByteRangeから除外され、未署名属性は署名計算に決して入らないため、「署名を生成→MACを構築→CMSを包む」というシーケンスは暗号学的ループなしできれいに閉じます。二つの帰結があります。/ByteRangeのセンチネルと/Contentsのプレースホルダーは、暗号化ファイルでも平文のままオブジェクトストリームの外に置かれなければなりません。固定幅パッチャーがそれらを見付けられなくなるからです。そしてMACダイジェストもSHA-256のときは署名ダイジェストがそのまま再利用され、そうでなければ両方のダイジェストコンテキストが出力ストリーム上の単一パスで更新されます

CMS署名に添付されたPDF MACに対するHotPDFの書き込み順序。MAC鍵はByteRangeが測定される前にリビジョンに入り、署名ダイジェストはその後、生のSignerInfo署名オクテットから構築される
ByteRangeが測定される前にAuthCode、KDFSalt、SigObjRef、開発者拡張を書き込むことこそ、MAC束縛を署名がカバーする範囲内に保つ
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // SHA-384の文書ダイジェスト
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature。二つのダイジェストは
        // 別々に報告される
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

検証は同じ経路を反対側から辿ります。現在有効なクラシッククロスリファレンストレーラーから直接の/AuthCodeを読み、世代を意識する間接の/SigObjRefを追い、それが一つの署名フィールドの/Vを束縛することを確認し、文書ダイジェストの失敗と署名ダイジェストの失敗を別々に報告します。これらは異なる診断であり、単一の真偽値に潰すと、ページコンテンツと署名値のどちらが触られたかを語る唯一の手がかりが捨てられます。CMS作業を既にしているなら、これはPAdES署名の記事読み込み済み文書の署名検証ガイドと並びます

/Pを信じない:16バイトの/Permsをまず復号する

ISO/TS 32004は「この文書はPDF MACを要求する」ことを権限ビット13で合図しますが、それを読む素直な方法こそが間違いです。暗号化辞書の/P整数は平文かつ未認証で、誰でもテキストエディタでそのビットを反転して要件を格下げできるからです。ISO 32000-2 §7.6が/Permsエントリで答えを供給し、HotPDFはそれを使います。16バイトの/Perms文字列をファイル暗号化鍵でAES-256 CBC、ゼロIV、パディングなしで復号し、何かを信じる前に平文の全フィールドを検査します。バイト1〜4はリトルエンディアンで権限値を保持し/P整数と正確に一致しなければならず、バイト5〜8は0xFF、バイト9はメタデータ暗号化フラグのTF、バイト10〜12はリテラルのマーカーadbです。そのすべてが成立してはじめてPermissionsAuthenticatedがTrueになりビット13が読まれます。極性に注意してください。MAC要件は0x1000ビットがクリアされているときに主張されます。/Pと復号済み権限の不一致は、記録して先へ進む警告ではありません。捏造された権限セットであり、正しい応答はフェイルクローズです

HotPDFは16バイトのPerms文字列をファイル暗号化鍵で復号し、リトルエンディアンの権限値、FFの充填バイト、メタデータフラグ、adbマーカーを検査してからビット13を読むことでPDF権限を認証する
平文の/P整数は未認証のため、PDF MAC要件は復号済み/Permsの全フィールド検査後に読まれる

アルゴリズムの柔軟性はダイジェストで止まる

ISO/TS 32004は文書ダイジェストを選ばせますが、文書ダイジェストだけです。HotPDFは可変のTHPDFPDFMACDigestAlgorithmpmdaSHA256からpmdaSHA3_512まで)の下に、認証はHMAC-SHA-256、鍵導出はRFC 5869のHKDF-SHA-256、鍵ラップはRFC 3394のAES-256を固定で保持します。「SHA3-512プロファイル」をHMACまで差し替える免許と捉えるのが自然な誤りで、それが生むファイルは相互運用可能な意味でのPDF MACではなくなります。独自の検証器を書くなら複写する価値のある実装細部があります。バイト範囲をハッシュするに、CMSのAuthenticatedDataからダイジェストOIDを読むことです。SHA-256をハードコードして後で照合すると、柔軟性はラベルに成り下がり、敵対的なファイルに、アルゴリズムが最初からサポートされていないと気づく前に文書全体をストリームさせられてしまいます。CMSAlgorithmProtectionAuthenticatedDataのダイジェストアルゴリズム、整合性情報のmessageDigest、バイト範囲ダイジェストはすべて単一のアルゴリズムを名指さなければならず、不一致はフェイルクローズです

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256、6種すべてを受け入れる
  Options := THPDFPDFMACOptions.Modern;         // SHA-384、256ビットを拒否
  Options := THPDFPDFMACOptions.HighAssurance;  // SHA3-512のみ、AES-GCM

  // カスタムプロファイルは合法だが、その生成に使うアルゴリズムも
  // 検証の許可リストに現れなければならず、さもなくば設定は
  // 一バイトも書かれる前に拒否される
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

PDF MACが証明することと、しないこと

検証済みのPDF MAC連鎖が証明するのは、保護されたすべてのリビジョンが、ファイル暗号化鍵を持つ誰かが書いたものとバイト単位で同一であること、保護リビジョンが削除も順序変更もされていないこと、アンカー以降に未保護リビジョンが追加されていないことです。まさに、単純なAES-256暗号化が放置する攻撃のクラスです。機密性は整合性について何も語らず、改変リビジョンを混ぜた暗号化PDFは無傷のものと同じように喜んで復号されます。証明しないのは作者性です。MAC鍵はファイル暗号化鍵から導出されるため、文書を開ける人は誰でも、正当な受信者全員を含めて、改変版の上に有効なMACを生成できます。対称プリミティブであり、対称プリミティブは帰属できません。誰が変更したかを知る必要があるなら、証明書に裏打ちされたデジタル署名が必要で、PDF MACはその後、署名だけではカバーしないインクリメンタル構造を保護する補完役になります。二つを層として扱い、二つの判定を一つのステータスアイコンに潰さず独立して報告させてください

ここで述べたPDF MACのエントリポイント群、AddStandalonePDFMACSignPDFWithPFXAndAttachedPDFMACValidatePDFMACValidatePDFMACChainは、DelphiとC++Builder向けの標準HotPDF Delphi Componentに同梱されています。オプションレコード、ステータス列挙、リビジョンごとの検証配列の完全なリファレンスは製品ページにあります