PDFが署名された後に何が変わったのかを突き止めるため、DelphiとLazarus向けPDFium ComponentはTPdf.AnalyzeSignatureRevisionsを提供します。これは署名後リビジョン変更アナライザーで、元のファイルバイトからすべての増分リビジョンを再構築し、後のオブジェクト変更1つ1つをその署名のDocMDPとFieldMDPルールに照らして採点し、シャドウオブジェクト定義を独立したリスクとして報告します。狙っている状況は契約書を扱う人なら見覚えがあるものです。認証済みフォームが送り出され、さらに2回の増分保存を経て返ってくるが、どの署名も検証を通る。これは想定内です。署名は自分のリビジョンのバイトしかカバーしないからです。本当の問いは、後の保存が許可されたものだったかどうかであり、署名の緑のチェックマークはそれに答えてくれません
PDFiumの署名APIが署名後の変更を示せない理由
PDFiumの署名APIには署名後の変更が示せません。読むのが署名ディクショナリだけだからです。/Contents、/ByteRange、/SubFilter、DocMDP権限値。PDFiumには増分リビジョングラフがなく、FieldMDPトランスフォームのパラメータもパースせず、リビジョン間のオブジェクトレベルdiffも持ち合わせていません。そのためFPdfPades.pasのアナライザーは生バイトを直接扱います。ここには設計時に織り込むべき実務上の帰結が1つあります。TPdf.AnalyzeSignatureRevisionsが読むのは、文書をロードしたときに保持されたバイトであって、SaveAsが作ったコピーでは決してありません。書き直されたファイルは、まさに分析対象のリビジョン構造を失っているからです。文書がまだダウンロードの終わっていないプログレッシブなソースから来た場合、レポートは切り詰められたファイルを分析する代わりにSourceStatus = pvssIncompleteとStatus = prasIndeterminateを返します
startxref、xrefストリーム、/Prevからリビジョン境界を再構築する
アナライザーは、ISO 32000-1 §7.5.6と§7.5.8が増分更新について定義するように、すべてのstartxrefをクラシックxrefテーブル、クロスリファレンスストリーム、ハイブリッドリファレンスの/XRefStmエントリ、/Prevチェーンをたどって遡ることで、リビジョン境界を再構築します。各署名のカバー長は2つ目のByteRangeスパンの終端であり、アナライザーはその長さを、それが属するxrefセクションのリビジョンへ写像します。一致するリビジョンがなければ、署名はprrCoveredRevisionNotFoundとIndeterminateステータスを受け取ります。続いて全オブジェクトの状態がカバーされたリビジョンまで再生され、その後の各xrefエントリがその状態と比較されます。これは聞こえ以上に重要です。ライターの中には増分保存のたびに完全なxrefテーブルを再記述するものがあり、同じ変更されていないオブジェクトをまだ指しているエントリは、変更として報告される代わりにスキップされます。この比較がなければ、まっとうなフォーム記入が何百もの偽の変更に沈みます
最も注意に値するのがシャドウ定義です。後のリビジョンのバイト範囲内に現れるのに、そのリビジョンのxrefから参照されないオブジェクトボディは、普通のビューアには見えません。しかしシャドウ攻撃が頼るのがまさにこの種の仕込みです。隠しコンテンツは署名の前後に植えられ、後で参照を付け替えることで活性化されます。AnalyzePadesSignatureRevisionsBytesはそのようなオブジェクトをIsAuthoritative = Falseの非権威ある変更として記録し、権限レベルにかかわらずprdSuspiciousを採点し、リスクセットにprrUnreferencedObjectDefinitionを加えます。関連する2つのリスクが他の構造トリックをカバーします。1つのxrefセクションが同じオブジェクトを2回以上列挙するとprrDuplicateObjectDefinitionが発火し、後のリビジョンが既存の署名オブジェクトを再定義するとprrSignatureObjectRedefinedが発火します
uses
SysUtils, TypInfo, PDFium, FPdfPades;
const
ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract-returned.pdf';
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions;
Writeln('Revisions: ', Report.RevisionCount,
' Signatures: ', Report.SignatureCount,
' Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
Ord(Report.Status)));
for i := 0 to High(Report.Signatures) do
with Report.Signatures[i] do
begin
Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
[SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
DocMdpPermission,
GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
for j := 0 to High(Changes) do
Writeln(Format(' rev %d obj %d %s -> %s%s',
[Changes[j].RevisionIndex, Changes[j].ObjectNumber,
GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
ShadowTag[not Changes[j].IsAuthoritative]]));
end;
finally
Pdf.Free;
end;
end;
署名ごとにDocMDPとFieldMDPはどう適用されるのか
DocMDPとFieldMDPは署名ごとに、その署名自身のカバーされたリビジョンで別々に適用されます。したがって同じファイル内の認証署名と後続の承認署名は、同じ編集について違う判定に達し得ます。後のオブジェクトはすべて、まず/Type、/Subtype、/FTエントリと、ページ・フォーム・注釈・DSSグラフで果たす役割からTPadesRevisionChangeKindへ分類されます。/JavaScript、/JS、/Launch、/OpenAction、/AA、/RichMedia、/EmbeddedFileを運ぶものはすべてprckActiveContentになります。判定は続いてISO 32000-1 §12.8.2.2に従います。P=1ではクロスリファレンスデータと検証素材を除くすべてが禁止、P=2はフォーム記入と追加署名を許すが注釈変更を拒否、P=3は注釈も許します。ページコンテンツ、文書構造、メタデータ、アクティブコンテンツ、削除されたオブジェクトはどのDocMDPレベルでも禁止であり、署名がDocMDPをまったく運ばないときはprdSuspiciousと採点されます。承認署名は形式的には何も禁じませんが、読み手はもう何が署名されたのか見えないからです
ISO 32000-1 §12.8.2.4由来のFieldMDPは、フォームフィールドの判定をさらに狭めます。pfmaAllは全フィールドをロックし、pfmaIncludeは列挙されたフィールドだけを、pfmaExcludeは列挙以外のすべてをロックします。IncludeやExcludeを適用するため、アナライザーは変更された各フィールドを/Parentチェーンを通して完全修飾名へ解決し、ロックリストと正確一致で比較します。したがってロックリストには末端のフィールド名を挙げるのであって、親名が子をカバーするのを期待してはいけません。名前が解決できないか、トランスフォームがパーサーの知らないアクションを使っている場合、その変更はprdIndeterminateとなり、prrFieldMdpUnresolvedが上がります。変更ごとの判定はその後worst-firstで畳み上げられます。SuspiciousがDisallowedより上、DisallowedがIndeterminateより上、IndeterminateがAllowedより上。1つのシャドウオブジェクトが、いくらの正当なフィールド更新をも上回るわけです
一部の変更が安全でなくIndeterminateで返る理由
アナライザーが変更の許可を証明できないとき、変更はIndeterminateで返ります。署名検査において、未知をallowedと報告してはならないからです。1つのよくあるケースは代わりに正確に処理されます。長期検証(LTV)は/DSSを追加しカタログを書き直しますが、これをそのままにするとP=1の下では構造変更として数えられてしまいます。アナライザーは新旧のカタログディクショナリから/DSSと/Extensionsを剥ぎ取り、残りを比較します。他に違いがなければ、書き直しは検証素材の更新として扱われ許可されます。B-LTやB-LTAの拡張が認証署名を壊さないのはこのためです。他のギャップは意図的に開いたままです。クロスリファレンスストリームのType-2エントリは圧縮オブジェクトストリームを指しますが、アナライザーはこのセキュリティ境界の内側ではオブジェクトストリームを展開しません。そのためこれらの変更はprrCompressedObjectUnresolved付きのprckCompressedObjectとして現れ、P=1では禁止、それ以外ではIndeterminateです。1024リビジョン、100万オブジェクト番号、200万報告変更というハードな予算を超えるとprrResourceLimitExceeded、壊れたxrefチェーンはprrMalformedRevisionChainになります。どちらも最後はIndeterminateであり、パスになることはありません
const
StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrResourceLimitExceeded];
function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
// 一部のリスクはStatusを変えずに記録されるため、先にテストする
if R.Risks * StructuralRisks <> [] then
Exit('review: structural risk in the revision chain');
case R.Status of
prasNoLaterChanges: Result := 'accept: nothing was added after signing';
prasAllowed: Result := 'accept: every later change is permitted';
prasDisallowed: Result := 'reject: a change violates DocMDP or FieldMDP';
prasSuspicious: Result := 'reject: shadow or unconstrained content change';
prasIndeterminate: Result := 'review: the analyzer could not decide';
else
Result := 'not checked: no signatures or no original bytes';
end;
end;
このゲートの順序は意図的です。prrDuplicateObjectDefinitionは、それ単体ではStatusを下げずにリスクセットへ追加されますし、パースできないFieldMDPトランスフォームも、フォームフィールドが実際に変わるまではステータスに影響しません。したがってStatusだけを見るゲートは、レポートが既に含んでいる証拠を見逃せます。レポートが主張しないことも頭に置いてください。TPadesRevisionAnalysisReportは、CMS署名が暗号学的に有効か、署名者証明書が信頼するルートへチェーンするかについては何も語りません。リビジョン分析は署名後に何が起きたかという問いに答えるものであり、構造検証と信頼検証の隣に置くべきものであって、その代わりではありません
署名時にシード値とMDPロックを書く
同じルールは、署名時にTPadesSignatureFieldOptionsを通して記述できます。これはTPadesSignOptionsとTPadesRemoteSignOptionsの両方が持つFieldOptionsメンバーです。PDFiumはウィジェットを作れますが、/SV、/Lock、FieldMDPやDocMDPのトランスフォーム、カタログの/Permsディクショナリは書けません。そこでコンポーネント独自の増分PAdESライターが、署名と同じxref更新の内側でこれらのオブジェクトを生成します。FieldNameがルートフィールド名を設定し、RequiredSeedValuesはISO 32000-1 §12.7.4.5が記述するシード値ディクショナリの/Ffビットになり、Reasons、LegalAttestations、AcceptableCertificatesが後の署名者の選択肢を制約し、LockFieldsと組にしたLockActionは間接の/SigFieldLockを書き、1から3のCertificationPermissionが署名を認証署名に変えます。DocMDPとFieldMDPのトランスフォームはどちらも、署名値の1つの/Reference配列に入り、それぞれの/Dataはカタログを指します
var
Options: TPadesSignOptions;
begin
Options := TPadesSignOptions.Default;
Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
Options.Reason := 'Approved for release';
Options.FieldOptions.FieldName := 'Certification';
Options.FieldOptions.CertificationPermission := 2; // フォーム記入と署名のみ
Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
Options.FieldOptions.LockAction := pfmaInclude; // これらのフィールドだけをロック
SetLength(Options.FieldOptions.LockFields, 2);
Options.FieldOptions.LockFields[0] := 'Total';
Options.FieldOptions.LockFields[1] := 'IBAN';
if not Pdf.SignPades('contract-certified.pdf', Options) then
Writeln('Signing failed');
end;
これを自前で組むと、いくつかの細部で間違えやすくなります。カタログの/Perms /DocMDPが参照しなければならないのはウィジェット注釈ではなく署名値ディクショナリであり、ライターはその理由で署名値を独立した間接オブジェクトとして保ちます。既存の/Permsディクショナリは既に/UR3の使用権を保持しているかもしれないため、ライターはISO 32000-1 §12.8.4の権限ディクショナリに従い、置き換えるのではなくコピーして/DocMDPを挿入します。既に/DocMDPを運ぶ文書は、2つ目の認証署名をEPadesCryptoで拒みます。整合しないオプションも同じです。フィールド名のないIncludeやExcludeロック、フィールドリスト付きのAllロック、非認証署名へのlegal attestation、ルートフィールド名に含まれるピリオド。リモート署名にはもう1つルールが加わります。PreparePadesRemoteSignatureが走る時点では署名証明書が未知だからです。そこでCertificateRequiredをセットするなら明示的なAcceptableCertificatesリストが要求され、ローカル署名なら解決済みの署名者証明書へフォールバックできます
リビジョン分析は署名ツールボックスを完成させるものであって、どの部分も置き換えません。まずPDFium ComponentでPDF署名とPAdESレベルを検査するから始めてディクショナリとベースラインレベルを読み、リビジョンの話の前に立ちはだかる構造的失敗についてはバリデータがPAdES署名を拒む理由を確認し、判定はJavaScriptや埋め込みファイルのチェックと並べて、より広いPDFセキュリティリスク監査へ織り込みましょう。TPdf.AnalyzeSignatureRevisions、TPadesSignatureFieldOptions、ここで示した増分PAdESライターは、Delphi、C++Builder、Lazarus向けPDFium Componentに付属します