技術記事

DocMDPとFieldMDP: DelphiでのPDFリビジョン監査

署名後に変更された署名済みPDFは、自動的に壊れているわけではない。ISO 32000-1は署名の上でのインクリメンタル更新を許可しており、そのうちの一部だけが署名者の設定したポリシーを破る。Delphi・C++Builder向けHotPDF Componentは、その問いにAnalyzeLoadedSignatureRevisionsで答える。これはすべての署名後リビジョンを分類し、DocMDPとFieldMDPに照らして採点する。このシナリオは、契約ソフトウェアを出荷している人なら誰もが知っているものだ:顧客が購入契約に署名し、それを送り出し、付属ページが添付されて戻ってくる。リーダーは署名は無傷だがドキュメントは署名後に変更されているという黄色いバーを表示し、部屋の誰も、それが通常の副署名ワークフローなのか、署名済み契約書を誰かがこっそり編集しているのかを言えない

署名後の変更が合法とみなされる条件

変更が合法であるのは、そのセマンティックカテゴリが証明署名の宣言した権限の内側に収まっている場合だ。ISO 32000-1 §12.8.2.2は、/P値が1、2、3のいずれかであるDocMDP変換を定義している:1は変更を一切許可せず、2はフォーム入力と署名を許可し、3はフォーム入力、署名、注釈を許可する。HotPDFはそれらをTHPDFDocMDPPermissionの値dmpNoChangesdmpFormFillAndSigndmpFormFillSignAndAnnotateとして公開し、dmpNoneはDocMDP変換をまったく持たない検査結果のために予約されている

これらのカテゴリには順序があり、その順序こそがチェック全体のエンジンだ。THPDFRevisionModificationLevelrmlNonermlLongTermValidationrmlFormFillAndSignrmlAnnotationsrmlOtherと続き、より大きな序数がより制限が緩いことにはならないよう意図的に配置されている。ドキュメント全体は、署名以降のすべてのリビジョンにわたって観察された最大レベルに帰着し、DocMDPの比較は単一の整数テストになる。1つ、早い段階で重要になるニュアンスがある:dmpNoChangesでも、この分析はrmlLongTermValidationを受け入れる。証明済みファイルにDSSとVRIの検証資料やドキュメントタイムスタンプを追加することは、ドキュメントの変更ではなく署名の保守であり、これを違反として扱ってしまうと、既存のあらゆる長期アーカイブワークフローが壊れてしまう

HotPDFはどのようにリビジョンチェーンを再構築するのか

構造的に、ヒューリスティックにではなく。ISO 32000-1 §7.5.6に従い、インクリメンタル更新は/Prevが前のセクションを指す新しいクロスリファレンスセクションを追記するので、HotPDFは末尾からstartxrefを読み取り、そこにあるセクションをパースし、/Prevを後方にたどって繰り返し、セクションを最も古いものから順に返す。そのループには2つの安全制限があり、失敗するファイルをトリアージする際に知っておく価値がある:すでに訪れたオフセットを指す/Prevは、空回りする代わりに明示的な循環診断でウォークを終了させ、千リビジョンを超えるチェーンは無条件に拒否される。どちらもAnalysis.Issueに現れ、関数はFalseを返す。どちらも取り繕われるべきではない。循環する/Prevは珍しいファイルではなく、不正形または敵対的なファイルだからだ

実際のドキュメントには4つの歴史的な形が現れ、いずれも処理される:行単位でパースされる従来型のxrefテーブル、/W/Indexフィールドを通じて解凍・デコードされるクロスリファレンスストリーム、従来型のトレーラーが/XRefStmキーを持ちそれが同じリビジョンにパース・マージされるハイブリッド参照ファイル(Officeプロデューサーのケースで、ハイブリッドクロスリファレンスストリームに関する記事で扱っている)、そしてObjStmコンテナの中に住むオブジェクト——これが重要になるのは、現代の更新では通常、変更された辞書を直接書き込むのではなく圧縮ストリームに入れるためであり、オブジェクトストリームとインクリメンタル更新に関する記事で説明されている。署名は分割点の基準になる:/ByteRange[2] + /ByteRange[3]SignedRevisionLengthになり、そのオフセット以降のすべてのセクションが署名後となる。バイト範囲が正しくハッシュされるかどうかは別の問題であり、VerifyLoadedSignatureによって答えられ、PDFデジタル署名の検証に関する記事で扱われている

変更された各オブジェクトはどのように分類されるのか

分類はオブジェクトごとに実行され、その後参照に沿って伝播する。署名後のセクションが触れたオブジェクト番号それぞれについて、HotPDFは新しい本体と、署名済みスナップショット時点での本体を読む。本体が同一であればrmlNoneだ——プロデューサーが変更せずにオブジェクトを書き直すことは実際にあるからだ。認識器は意図的に狭く作られている。/Type /DocTimeStampオブジェクト、または/SubFilterETSI.RFC3161であるオブジェクトはrmlLongTermValidationであり、カタログの/DSSツリーから到達可能な何かも同様だ。/Type /Sig辞書はrmlFormFillAndSignだ。コンテナに対するテストは、そのオブジェクトが何であるかではなく、どのキーが動いたかだ:カタログは/DSS/Extensions/AcroFormだけを取得または変更してよく、AcroForm辞書は/Fields/SigFlags/NeedAppearances/DR/DA/Qだけ、ページは/Annotsだけ、フィールドやウィジェットは/V/AP/AS/Mだけだ。これらの集合の外にあるものはすべてrmlOtherに落ち、それがまさに追加された付属ページを捕捉する仕組みだ:ページの追加はどんなホワイトリストもカバーしない方法でページツリーを再配置し、正当なフォーム入力のどれもそれには似ていない

その後レベルは伝播し、各コンテナは指している変更済みの子の最大レベルを継承し、割り当てが安定するまで繰り返される。これが外観ストリームを機能させる理由だ。塗りつぶされたテキストフィールドは/Vを書き換え、新しい/APストリームを指す。そのストリームだけを見ると、認識すべき型を持たないコンテンツオペレータの匿名の塊だ。しかし、それを所有するフィールドがrmlFormFillAndSignなので、そのストリームはrmlOtherに落ちる代わりに同じレベルを継承する。同じ伝播が、そうでなければ分類不能な証明書と失効ストリームにDSSのコンテキストを運ぶ

なぜ読めないオブジェクトが違反としてカウントされるのか

代替案は、理解できないものを書くことでバリデータを打ち負かすことになるからだ。HotPDFでは、3つの状況が異議なくrmlOtherで終わる:オブジェクトの本体がリビジョンから読み取れなかった場合、オブジェクトがそのリビジョンでフリー(解放)としてマークされている場合、上記の認識器のどれにも一致しない場合。それぞれがIssueフィールドに具体的な診断を記録するので、オペレーターはどのオブジェクト番号がその判定を生んだかを見ることができる

フリー化は3つの中で最も鋭いものだ。以前に定義されていたオブジェクトをフリーとしてマークする署名後リビジョンは、署名済みドキュメントからコンテンツを削除したのであり、§12.8.2.2の下ではどの権限レベルもそれを許可しない。オブジェクト番号はFreedObjectNumbersに入り、リビジョンはrmlOtherに引き上げられる。読めないオブジェクトも、別の理由で同じロジックに従う。オブジェクトをパースできないバリデータには、それを無害と呼ぶ根拠がなく、それに対する誠実な応答は沈黙ではない。異常だが無害な構造を違反として報告することは人間によるレビューを1回消費するが、その逆の間違いは、気づかれない編集を含んだ署名済み契約書を出荷することになる

Delphiで判定を読む

呼び出しは短い。ドキュメントを読み込み、署名インデックスを選び、レコードを読む。引数なしのオーバーロードは、ドキュメントが読み込まれた元のファイルを再度開き、TStreamのオーバーロードは呼び出し側が供給したバイトを受け取り、戻る前にストリーム位置を復元する。PolicyCompliantは、ほとんどの呼び出し側が求める単一のブール値であり、3つの独立した判断を組み合わせている:権限辞書の構造的妥当性、DocMDPCompliantFieldMDPCompliantだ。これらの構成要素はUI上でまとめずに可視にしておくべきであり、DocMDP変換を持たないドキュメントはDocMDPCompliantTrueのままにすることに注意してほしい。通常の承認署名は違反すべきポリシーを宣言していないため、集約されたModificationLevelはそのとき判定ではなく記述的な性質を持つ

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

トリアージのためには、通常サマリーではなくリビジョンごとの内訳が欲しくなる。それがドキュメントの歴史のどの時点でおかしくなったかを教えてくれるからだ。Analysis.Revisionsの各エントリは、チェーン内でのインデックス、書き込まれたクロスリファレンスのオフセット、独自の変更レベル、関与したオブジェクト番号を持つ

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDPは別途判定される、それは意図的だ

ドキュメントはDocMDPを満たしつつ、それでも不正であり得る。だからこそFieldMDPCompliantはレベル比較に折り込まれるのではなく別個のブール値になっている。ISO 32000-1 §12.8.2.4はFieldMDP変換を定義し、§12.7.5.5は関連する/SigFieldLockエントリを定義しており、これらはドキュメント全体としてはまだフォーム入力を許可している場合でも、署名時点で名前指定されたフォームフィールドを凍結する。フィールドを埋めることはレベル2のアクションだが、署名者がロックしたフィールドを埋めることは、レベルにかかわらず違反だ。HotPDFはそのスコープをTHPDFFieldLockActionとしてflaAllflaIncludeflaExcludeに読み込み、flaNoneはロックポリシーを持たない結果のために予約されており、名前はPermissions.FieldNamesに読み込まれる:flaAllはすべてをロックし、flaIncludeはリストされた名前をロックし、flaExcludeはそれら以外のすべてをロックする。結果を読む際に重要な細部が1つある:ChangedFieldNamesに報告されるのは、署名済みスナップショットにすでに存在していたフィールドだけであり、署名後に完全に新しく作られたフィールドには矛盾すべき署名済み状態がなく、代わりにDocMDPパスによって捕捉される

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

この分析が教えてくれないこと

署名を検証するわけではない。AnalyzeLoadedSignatureRevisionsは構造と権限について推論するものだ。署名されたバイト範囲がCMSブロブ内の値に今もハッシュされるかどうか、署名者の証明書があなたが信頼している何かにチェーンするかどうかは、VerifyLoadedSignatureVerifyLoadedSignatureWithTrustによって答えられる。ファイルは完全にポリシー準拠でありながら暗号学的には無価値であり得るので、この2つのチェックは実際の受け入れゲートにおいて並んで存在すべきものだ。またコンテンツストリーム内の意図を読むこともしない:コンテンツストリームが丸ごと置き換えられたページはホワイトリスト外の変更として捕捉されるが、その分析は置き換えが支払い金額を差し替えたことまでは教えてくれない。rmlOtherという判定は不正が発生したことを意味するのではなく人間が見るべきだということを意味し、準拠という判定は変更が望まれたものであることを意味するのではなく変更が許可されたカテゴリに収まっていることを意味する。リビジョンウォークなしで署名者が宣言した内容だけが必要な場合は、GetLoadedSignaturePermissionsがポリシー辞書だけを単独で返す

ここで説明したことはすべて、外部の署名サービスをループに入れることなくDelphiとC++Builderでネイティブに実行される。だからこそ、疑わしいと誰かがすでに気づいていたものだけでなく、すべての受信ドキュメントに対して実行することが実用的になる。この署名・リビジョンAPIの全体は、それが組み合わさる権限・検証メソッドとともに、Delphi・C++Builder向けHotPDF Componentの一部だ