技術記事

Delphi の FieldMDP: ロック済みフォームフィールドの改ざん検出

PDFlibPas は FieldMDP の問いに TPDFlib.GetSignatureModificationVerdict で答えます。署名済み PDF とそのパスワード、署名フィールド名を渡すと、このメソッドはその署名の後に付加された全インクリメンタル更新をたどり、更新が触れた各フォームフィールドの完全修飾名を解決し、FieldMDP でロックされたフィールドが変更されていれば 3 を、ロックされていないフィールドだけの変更なら 2 を返します。続く GetSignatureModificationDetail がリビジョンとフィールドの名前を教えてくれます

これが必要になるワークフローが複数承認者のフォームです。申請者が購買申請の Order.Customer と Order.Total を記入し、この 2 つをロックする形で Approval.Requester に署名します。マネージャーがコストセンターを追記し、Approval.Manager に署名する。2 週間後、誰かがデスクトップエディタでファイルを開き、3 つ目のインクリメンタル更新で Order.Total を水増しします。それでもすべての署名は検証を通ります。各署名がカバーするのは自分のバイトレンジだけだからです。監査人が実際に問うのは、どの署名の後にどのフィールドが変わったのか、そしてその変更が許可されたものだったのか、の 2 点です

DocMDP だけでは改変されたロック済みフィールドを捉えられない理由

DocMDP は変更の種類を分類する仕組みであって個々のフィールドを扱う仕組みではないため、捉えられません。ISO 32000-1 §12.8.2.2 の証明権限レベルは、後続の更新にフォーム記入と署名までを許す(P 2)のか注釈まで許す(P 3)のかを定め、その権限は文書内の全フィールドに等しく適用されます。Order.Total の編集はフォーム記入ですから、P 2 の証明は申請者が禁じたかったまさにその改変によって満たされてしまいます。PDFlibPas の文書レベル変更分類の記事がその層を扱っており、本記事はさらに 1 段細かい粒度の話です

FieldMDP はフィールド粒度の層です。署名フィールドは /Lock 辞書(ISO 32000-1 §12.7.4.5)を保持でき、そこに格納される署名は署名参照の中に FieldMDP トランスフォームを保持できます(ISO 32000-1 §12.8.2.4)。両者とも語彙は共通で、/Action /All は全フィールドをロック、/Action /Include は /Fields に列挙された名前をロック、/Action /Exclude は /Fields の名前以外のすべてをロックします。v3.586.0 より前の GetSignatureModificationVerdict は、後続リビジョンがフォームフィールドを変更したことは分かってもどのフィールドかは分からず、フィールドロックを持ち検証素材を超える更新を含む文書は、不定を意味する 4 で返ってきました。リビジョンマップが分類するフィールド関連オブジェクトごとに解決済みのフィールド名を記録するようになったことが、この 4 を実際の 2 か 3 に変える鍵です

承認者が署名する際にフィールドロックを書き込む

TPDFlib.SetSignProcessFieldLock は、署名プロセスが生成する次の署名に対するロックを設定します。アクション 1 が All、2 が Include、3 が Exclude、0 はロック解除です。Include と Exclude には完全修飾フィールド名をセミコロンで区切ったリストを渡します。署名されたフィールドには /Lock 辞書と対応する FieldMDP トランスフォームが付き、厳格なバリデータが期待する組み合わせになります

Delphi の FieldMDP 署名における PDFlibPas SetSignProcessFieldLock のアクションコード。アクション 1 の All は後続の署名フィールドを含む全フォームフィールドをロックし、アクション 2 の Include は名指しされたデータフィールドをロックし、アクション 3 の Exclude はリスト以外のすべてをロックし、アクション 0 はロックを解除する
各承認者が所有するデータフィールドには Include を、後続の署名フィールドを列挙する形なら Exclude を選びます。拒否された呼び出しは 0 を返し前のロックが残り続けるため、戻り値の確認を省かないでください
var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('purchase-request.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open the form');
    try
      Pdf.SetSignProcessField(SignId, 'Approval.Requester');
      Pdf.SetSignProcessPFXFromFile(SignId, 'requester.pfx', PfxPassword);
      // 2 = Include: この署名で凍結されるのはこのフィールドだけ
      if Pdf.SetSignProcessFieldLock(SignId, 2,
        'Order.Customer;Order.Total') <> 1 then
        raise Exception.Create('field lock rejected');
      Pdf.EndSignProcessToFile(SignId, 'purchase-request-signed.pdf');
      if Pdf.GetSignProcessResult(SignId) <> 1 then
        raise Exception.CreateFmt('signing failed, code %d',
          [Pdf.GetSignProcessResult(SignId)]);
    finally
      Pdf.ReleaseSignProcess(SignId);
    end;
  finally
    Pdf.Free;
  end;
end;

SetSignProcessFieldLock は、名前のない Include や Exclude の呼び出しに対して 0 を返します。'Order.Customer;;Order.Total' のような空エントリを含むリストでも同様です。拒否された呼び出しはそれまでに設定済みのロックをそのまま残すため、直前の呼び出しが成功したと決め込まず戻り値を確認してください。ダイジェスト選択や /Contents の予約、結果コードなど署名設定の残りの部分は、PDFlibPas の PAdES 署名と検証ガイドが扱います。承認チェーン固有の設計ポイントが 1 つあります。申請者署名の /All ロックは Approval.Manager も凍結します。署名フィールド自体もフォームフィールドだからです。各承認者が所有するデータフィールドには Include を、後続の署名フィールドを列挙する形なら Exclude を使ってください

承認チェーンを横断して判定を実行する

GetSignatureModificationVerdict は 1 回の呼び出しで 1 つの署名を判定するので、承認チェーンの検査は署名フィールドごとに呼び出すことになります。戻り値は小さな閉じた集合で、レビュワーにとってそれぞれ意味が違います

  • 0: ファイルまたは指定された署名フィールドを読み取れなかった
  • 1: 署名の後には何も付加されていない
  • 2: 実効権限が許す変更だけが付加され、ロック済みフィールドには触れていない
  • 3: 後続リビジョンが許されない変更を行った。FieldMDP がロックするフィールドへの編集もこれに含まれる
  • 4: 判定できなかった。承認として読んではならない
  • 5: 署名がリビジョン境界で終わっておらず、その範囲が主張する中にバイトが入り込んでいる
const
  Approvals: array[0..2] of WideString =
    ('Approval.Requester', 'Approval.Manager', 'Approval.Finance');
var
  Pdf: TPDFlib;
  I, Verdict: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    for I := Low(Approvals) to High(Approvals) do
    begin
      Verdict := Pdf.GetSignatureModificationVerdict(
        'purchase-request-final.pdf', FormPassword, Approvals[I]);
      // 両ゲッターとも直前の呼び出しの結果を返すので、次の呼び出しの前に読む
      Log.Add(Format('%s: verdict %d, MDP level %d, %s',
        [Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
         Pdf.GetSignatureModificationDetail]));
      case Verdict of
        1, 2: ;                                 // 受け入れ
        3: RejectForm(Approvals[I]);            // detail にフィールドとリビジョンが現れる
      else
        QueueForManualReview(Approvals[I]);     // 0, 4, 5
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

改ざんされた購買申請を例に取ります。リビジョン 0 が空フォーム、リビジョン 1 と 2 が 2 つの署名、リビジョン 3 が改変です。ロック定義は署名済みフィールドすべてからプールされるため、Approval.Manager の判定にはすでに申請者のロックが効いており、その後に付加される更新は改変そのものだけです。この更新がフィールド辞書だけを書き換えた場合、判定は 3 となり、detail は Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow. と読めます。この 1 行で監査の 2 つの問いに両方答えられます。Approval.Requester の判定は、まずマネージャー署名のリビジョンを通過しなければならず、後述の制限事項の理由からしばしば 4 を報告します。つまり、いちばん鋭い答えを返すのは通常最新の署名です。GetSignatureEffectiveMDPLevel は署名の中で見つかった最小の DocMDP P 値を報告し、文書を証明した署名がなければ 3 を返します。両ゲッターとも次の判定呼び出しまでしか有効ではありません

PDFlibPas GetSignatureModificationVerdict が読む改ざん済み Delphi PDF フォームのインクリメンタル更新タイムライン。リビジョン 1 は Order.Customer と Order.Total への Include ロックを担い、リビジョン 2 は Approval.Manager に署名し、リビジョン 3 でのロック済み Order.Total フィールドの書き換えによりマネージャー署名の判定は 3、申請者署名の判定は 4 になる
ロックは署名済みフィールドすべてからプールされるため、Approval.Manager の判定にはすでに申請者のロックが効き、detail 文字列は違反フィールドとリビジョンを名指します。一方、最初の署名はしばしば 4 に着地します

PDFlibPas はリビジョンがどのフィールドを変更したかをどう知るのか

PDFlibPas は各リビジョンのオブジェクトグラフからフィールド名を再構築します。変更されたオブジェクトが完全名を持っていることはまれだからです。/FT や /V を持つフィールド辞書、ウィジェット注釈は、/T に部分名しか持っていません。ISO 32000-1 §12.7.3.2 に述べられる完全修飾名は、ルートからの部分名の連鎖をピリオドでつないだものなので、リビジョンマップは /Parent リンクを上方へたどり、見つけた /T を前方に付け足していきます。各親は、分類対象のリビジョンの時点またはそれ以前の最新の定義から読まれ、これにより親ノードの後年の改名が以前の編集の歴史を書き換えることがありません

Delphi 向け PDFlibPas における FieldMDP の完全修飾名解決。リビジョンマップは変更されたウィジェットから /Parent リンクを上方へたどり、各 /T の部分名をピリオドでつないで Order.Total を組み立て、/Fields エントリと完全一致またはピリオド接頭辞で照合するため、Order へのロックがすべての子孫をカバーする
変更されたオブジェクトが完全名を持つことはまれです。親は分類対象リビジョンの時点以前の最新定義から読まれ、名前の比較は大文字小文字を区別し、たどりは 8 レベルで打ち切られます

解決した名前とロックの照合は意図的に単純です。名前は /Fields エントリと完全に一致するか、エントリの後にピリオドが続く形で始まる場合に一致とみなされます。つまり Order をロックすれば Order.Total とその他すべての子孫もロックされます。All はすべての名前をロックし、Include は一致する名前をロックし、Exclude は一致しないすべての名前をロックします。ロック定義はファイル内の署名済み署名フィールドすべてから集められ、フィールドが /Lock 辞書と FieldMDP トランスフォームの両方を持つ場合は /Lock の値が使われます。各署名が何をロックしたかを正確に見るには、PDFlibDigitalSign ユニットの下位クラス TPDFlibSignDoc が、判定の読む権限レポートを公開しています

uses
  PDFlibDigitalSign;

var
  SignDoc: TPDFlibSignDoc;
  Report: TPDFlibSignaturePermissionReport;
  Names: TStringList;
  I, J: Integer;
  Line: string;
begin
  SignDoc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not SignDoc.Open('purchase-request-final.pdf', FormPassword, False) then
      Exit;
    try
      SignDoc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
      begin
        SignDoc.GetSignaturePermissionInfoByName(Names[I], Report);
        if not (Report.HasFieldLock or Report.HasFieldMDP) then
          Continue;
        Line := Names[I] + ': ' + string(Report.FieldLockAction);
        for J := 0 to High(Report.FieldNames) do
          Line := Line + ' ' + string(Report.FieldNames[J]);
        if Report.HasFieldLock and Report.HasFieldMDP and
          not Report.FieldMDPMatches then
          Line := Line + ' (lock and FieldMDP transform disagree)';
        Log.Add(Line);
      end;
    finally
      SignDoc.Close;
    end;
  finally
    Names.Free;
    SignDoc.Free;
  end;
end;

暗号化フォーム:パスワードを渡さないと名前が読めない

暗号化フォームはフィールド名を解決できるようになる前に復号されなければならないため、GetSignatureModificationVerdict には Password 引数がファイルを開く以上の役割があります。/T のような文字列はオブジェクトごとに暗号化され、圧縮オブジェクトストリームに書き込まれたフィールドは、格納ストリームを復号して展開するまでまったく読めません。v3.586.0 から内部のリビジョンマップはパスワードを受け取り、オブジェクトストリームとリビジョン辞書をパースする間、復号コンテキストを装着して、デコードする全オブジェクトを復号します。差分を取る過去バージョンやたどる親も含めてです

リビジョンマップのオブジェクトごとの復号は、Password が空でない場合にだけ有効になります。したがって、空パスワードで暗号化ファイルに対して計算した判定は、文書自体がプロンプトなしで開けたとしても未検証として扱うべきです。フォームのパスワードをワークフローレコードのそばに保存し、毎回の呼び出しで渡してください

FieldMDP 判定がカバーしない範囲

フィールド検査は 1 つの狭い問いへの構造的な答えであり、いくつかのケースはその外側にあります。あらかじめ知っておくことが、4 をサポート問い合わせに変えないためのコツです

  • フィールド比較が走るのは、文書レベルの検査がクリアし、少なくとも 1 つの後続リビジョンが検証素材やタイムスタンプを超える変更をした後だけです。DocMDP 違反はフィールドを見る前に 3 として報告されます
  • 署名値辞書、書き換えられた AcroForm 辞書、/AcroForm や /Perms に限ったカタログ変更のように、署名関連と分類されながらフィールド名を持たないオブジェクトは、推測ではなく判定 4 の原因になります。アクティブなロックの下で後続の承認署名を追加するリビジョンはしばしば 4 に着地します
  • フィールド名を得るのはフィールド辞書とウィジェット注釈だけです。リビジョンマップにアピアランスストリーム専用のクラスはないため、値と一緒にアピアランスを再生成するエディタは、フィールド検査が走る前にコンテンツ変更として文書レベルの検査で止められることがあり、detail 文字列が両者を区別します
  • 走査は最初に遭遇したロック済みフィールドまたは名前なしエントリで止まるため、3 が名指すのは違反フィールドの 1 つであって全部ではありません
  • ロックは時間順に並べられずに署名済み署名フィールドすべてからプールされるため、後の署名者が書いたロックはそれ以前の更新にも適用されます。結果が押されるのは 3 の方向だけで、2 の方向へは決してありません
  • 名前は完全一致かつ大文字小文字を区別して比較され、/Parent のたどりは 8 レベルで打ち切られるため、異常に深い階層は切り詰められた名前に解決されます
  • 構造が検証を通らずアクションも読めない FieldMDP エントリは、判定を 4 にします

判定が 4 で返ってきたら、GetSignatureRevisionIndex が署名が終わるリビジョンを教え、GetDocumentRevisionChanges が各後続リビジョンが追加・変更・解放した全オブジェクトをその変更クラス付きで列挙します。人が 1 分で決断するには十分です。それらのリビジョンがどう付加されるかの仕組みは、PDFlibPas のインクリメンタル更新ガイドが扱います

FAQ

判定 2 は署名が有効という意味か

いいえ。GetSignatureModificationVerdict は署名の後に何が付加されたかを調べるのであって、CMS 署名値、証明書チェーン、失効状態は一切検査しません。2 が言うのは後続の更新が権限内に収まっていたことだけです。暗号学的な検証は別のステップで、両方が必要です

2 人目の承認者の署名で 2 ではなく 4 になるのはなぜか

署名は署名値辞書を付加し、たいてい AcroForm 辞書のようなフォームの帳簿も書き換えます。これらのオブジェクトはどの名前付きフィールドにも属さず、フィールドロックが有効なとき、判定はフィールドに帰属させられない変更を通しません。リビジョンに期待どおりの署名オブジェクトだけが含まれることを確認するには GetDocumentRevisionChanges を使ってください

1 つの名前でフィールドのグループ全体をロックできるか

はい。ロックエントリは名指しされたフィールドと、完全修飾名がそれにピリオド続きで始まるすべての子孫に一致します。Include ロックに Order を列挙すれば、Order.Customer、Order.Total、その親の下のその他すべてのフィールドが凍結されます

FieldMDP 判定、フィールドロック署名、リビジョン検査はすべて PDFlibPas Delphi PDF Library に同梱されています。上記の承認ワークフローが依存する DocMDP 証明や PAdES 長期検証機能も一緒です