契約書に署名が押され、そのあと 1 回だけ更新が追記される。監査スクリプトはその更新を署名済みリビジョンに折り畳んだり、書き換えられたページを検証マテリアルとして素通しさせたりしがちです。リビジョンの終わりを startxref が /Prev チェーンで到達したセクションを名指す位置に定め、書き換えオブジェクトには前の版の分類を保たせる。PDFlibPas の revision map を支えるこの 2 つの規則があれば、どちらの事故も起きません
%%EOF はファイルを切り分ける場所として間違っている理由
増分更新はそれぞれ自分自身の end-of-file マーカーで終端を迎えます(ISO 32000-1 §7.5.6)。だから各 %%EOF でファイルを切り分けるのがリビジョン探しの定石に見えるのです。ですがマーカーは 5 バイトに過ぎず、そのバイトが content stream や埋め込みファイル、フォントプログラムの中に紛れ込むのを止める規則はどこにもありません。Linearized ファイルはもう 1 つ難題を抱えます。Annex F(§F.3.4)は first-page trailer がファイル先頭付近で、main の cross-reference テーブルを大きく先回りして、独自の startxref と %%EOF で終わることを許します。すべてのマーカーで切り分けると、どの cross-reference セクションも裏付けしないリビジョンが捏造されます。どのマーカーが偽物かを推測するのはもっと悪く、その推測こそ偽造者が自由に形を決められる場所です。PDFlibPas の revision map は逆向きに動きます。/Prev チェーンをたどり、チェーンが実際に読んだすべての cross-reference セクションのバイトオフセットを記録し、それから startxref N … %%EOF の並びを走査して、N が記録済みオフセットの 1 つであるときに限り境界を残します。ストリーム内の迷子マーカーが何かとして数えられるには、手前に実在のセクションを名指す startxref が必要です
この 3 つのトークンの間のバイトは、§7.5.5 が描くよりずっと不潔です。規格は startxref、オフセット、マーカーを連続する行に描いていますが、end-of-line マーカーと §7.2.2(Table 1)の他の空白文字は繰り返してよく、上限を定める規則はありません。パディングされたマーカーを 1 つ読み漏らせば、2 つのリビジョンが融合します。署名済みのリビジョンは、誰も署名していないバイトの後で終わったように見えてしまう。PDFlibPas の revision map は、キーワードからオペランド、マーカーへの並びを、どれだけ空白が挟まろうと、そのパディングがファイルのどこに落ちていようと、そのまま追いかけ、各キーワードは 1 回しか数えません。trailer キーワードにも似たような緩い継ぎ目があります。<< はデリミタ(§7.2.2、Table 2)で、§7.5.5 は trailer の後の改行を要求しないので、1 行の trailer<< は規格に適合する構文です。行ごとに読むと、その行は cross-reference サブセクションのヘッダーに見えてしまい、チェーンは途中で止まります
PDFlibPas で revision map を歩く
リビジョン用のアクセサは TPDFlib で宣言され、Small Business edition に実装があります。PDFlibSmallBusiness ユニットから TPDFlibSmallBusiness を作成してください(Enterprise はこれを継承し、同じ呼び出しを備えます)。素の TPDFlib では失敗値(0、7、-1)を返し、LastErrorCode に PDFLIB_ERROR_SMALLBUSINESS_REQUIRED を設定します。リビジョンは元の文書を 0 として番号が振られます。各呼び出しはファイル名とパスワードを取り、ディスク上のバイトから map を組み立てます。答えが記述するのは格納状態のファイルであって、たまたま読み込んで編集した文書ではありません。GetDocumentRevisionKind はリビジョン内で最も遠くまで及ぶ変更を報告します。OCSP レスポンスを追加しつつページも書き換えた更新は、ページ書き換えとして報告されます:
| 種別 | 意味 | 典型的な出どころ |
|---|---|---|
| 0 | ハウスキーピングのみ:読み手に見えるものを何も変えないオブジェクト | cross-reference ストリーム、新しいオブジェクトストリームのコンテナ |
| 1 | 検証マテリアル | document security store、VRI、証明書、CRL、OCSP レスポンス |
| 2 | ドキュメントタイムスタンプ | /DocTimeStamp 署名とそれを運ぶフィールド |
| 3 | 署名 | 値を受け取った既存の署名フィールド |
| 4 | フォームフィールド | フィールド値またはその見た目 |
| 5 | 注釈 | widget 以外の任意の注釈 |
| 6 | 文書コンテンツ | 文書の見た目を変え得るものすべて |
| 7 | 判定不能 | リビジョン内の何かが読めなかった。安全でないものとして扱う |
uses
System.SysUtils, PDFlibSmallBusiness;
procedure ListRevisions(const FileName, Password: WideString);
const
KindNames: array[0..7] of string = ('housekeeping', 'validation material',
'document time stamp', 'signature', 'form field', 'annotation',
'content', 'undetermined');
var
Pdf: TPDFlibSmallBusiness;
Count, I, J, Kind, Changes: Integer;
begin
Pdf := TPDFlibSmallBusiness.Create;
try
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
if Count = 0 then
raise Exception.Create('the cross-reference chain could not be followed');
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Writeln('WARNING: history incomplete, every figure below is a lower bound');
for I := 0 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind < 0) or (Kind > 7) then
Kind := 7;
Writeln(Format('revision %d: bytes %d..%d, %s', [I,
Pdf.GetDocumentRevisionStart(FileName, Password, I),
Pdf.GetDocumentRevisionEnd(FileName, Password, I),
KindNames[Kind]]));
// オブジェクトごとに 'objnum,change,class,detail' の 1 行
Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
if Changes <> 0 then
try
for J := 1 to Pdf.GetStringListCount(Changes) do // 項目は 1 から数える
Writeln(' ' + Pdf.GetStringListItem(Changes, J));
finally
Pdf.ReleaseStringList(Changes);
end;
end;
finally
Pdf.Free;
end;
end;
他を信じる前に読むべき呼び出しが GetDocumentRevisionsComplete です。1 を返すのは、チェーンが最初のセクションまで完全にたどれたときだけ。切れたりループしたりした /Prev、あるいは 256 リンクを超える深さのチェーンは最古の更新を見えなくし、その時点ですべての個数とインデックスは下限に変わります。深さ 256 は実ファイルの保存と署名の歴史に十分な余裕で、自分自身を指すチェーンでもほぼ一瞬で終わります。ループのインデックスには注意を。GetStringListItem は 1 から数え、リビジョンは 0 から数えます。内側のループを 0 から始めると、エラーなしで毎リビジョンの最初の変更オブジェクトが脱落します
偽造更新はどうやって検証マテリアルのふりをするか
PAdES の長期検証は、最後の署名のあとに正当に更新を追記します。document security store(/Type /DSS)、署名ごとの /VRI 辞書、証明書と失効情報のストリーム、ドキュメントタイムスタンプです。認証レベルはこれらを通すので、検証マテリアルは更新が着られる最も価値のあるラベルになります。そして最も安価に偽造できるラベルでもあります。/Type は更新が書くと決めたものそのままです。ページを描くビューアは、content stream やリソース辞書、フォントの /Type を決して見ません。だから更新はページ 3 の content stream を書き換え、その辞書へ /Type /DSS を足しても、すべてのレンダラは新しいテキストを描き続け、/Type を信じる分類器はハウスキーピングを見るだけです。/VRI、/Sig、/DocTimeStamp も同じ仕組みです
PDFlibPas の revision map は、更新が書き換え対象のオブジェクトに書き込むラベルを無視します。書き換えオブジェクトが検証マテリアル、タイムスタンプ、署名として数えられるのは、同じオブジェクトの前の版がすでにそうだったときだけです。証明書、CRL、OCSP レスポンスの各ストリームは自分の型を持たないので、それらを指す document security store で認識されます。ただし数えられるのは、判定中のリビジョンかそれより古いリビジョンで書かれたストアだけです。後から追加されたストアは、時を遡って署名済みリビジョンが書いたオブジェクトの保証人にはなれません。文書の構造への変更は、読み手の解決結果を変えるときはいつでも content と数えます:
- 既存のオブジェクトストリームの置き換え。中の圧縮オブジェクトがすべて置換先へ解決されるようになるからです(真っ新なコンテナはハウスキーピングのまま。各メンバーは独自のエントリを持つので、そこで判定される)
- catalog かページの追加
- 別の catalog を
/Rootで名指す trailer。署名が覆うものを何も再定義していなくても、root の差し替えは署名オブジェクトに 1 つも触れずに文書全体を置き換えます - 辞書が旧版とバイト単位で同一な書き換え済みストリーム。間接参照の
/Lengthや同サイズに詰められた置換ボディは、描画が変わっても辞書を無傷に保つので、その証拠だけではストリームボディを持たないオブジェクトしか無変更扱いされません
オブジェクトストリームのメンバーに剥ぐべきヘッダーはない
PDF 1.5 からは、更新が書き換えオブジェクトをオブジェクトストリームの中に格納できます。§7.5.7 はこの形式について明快です。格納されるのはオブジェクトの値だけで、obj と endobj のキーワードは使ってはならない。オブジェクトをその位置で読み、obj という文字列を探してヘッダーを取り除くコードは、メンバーの先頭では何も見つけられず、ボディの中へ探し続けます。署名後に書き換えられたページ辞書に (obj<</Type/DSS>>) のような無害そうな文字列を忍ばせれば、その探索は文字列の中で止まります。デコーダが受け取るのは <</Type/DSS>>——そのリビジョンが書いたことのないストア辞書です。PDFlibPas の revision map は、非圧縮オブジェクトの先頭から N G obj をパースし、番号が期待したオブジェクトを名指すかを確かめ、ヘッダーがない、あるいは一致しない場合はさらに掘る代わりに読み不能として扱います
cross-reference ストリームにはフィールドレベルの曖昧さも備わっています。§7.5.8.3(Table 18)では、バイトオフセットを持つのは type 1 エントリだけです。type 2 エントリの第 2 フィールドは収容先のオブジェクトストリームのオブジェクト番号、第 3 はその中でのインデックス、type 0 エントリの第 2 フィールドは次の空きオブジェクト番号を意味します。このオブジェクト番号をオフセットとして読めば、圧縮エントリはそれらの小さなバイト位置を覆うリビジョン——たいてい元の文書——に落ち着き、書き換えが署名済みバイトの内側に隠れます。revision map はすべてのエントリを、それを宣言した cross-reference セクションごとに配置します。同じ条項は 0、1、2 以外のエントリ型を null オブジェクトへの参照としており、その種のエントリは何も追加も解放もしないので、map には載りません
署名監査はいつ答えを拒むべきか
ファイルが自分の歴史の一部を隠しているとき、そのつどです。revision map はそれを 3 通りで報告します。GetDocumentRevisionsComplete が 0 を返す、GetDocumentRevisionKind が署名後のあるリビジョンで 7 を返す、GetSignatureRevisionIndex が -1 を返す。最後の値は、リビジョン境界で終わらない ByteRange——署名が覆うと主張する範囲の中にバイトが座っている——に加えて、存在しないフィールド、署名値を持たないフィールド、読めなかったファイルをも同じ扱いにします。3 つすべてに、リビジョンインデックスと GetDocumentRevisionChanges の行を添えて手動レビューへ回しましょう。ファイルを開く人がオブジェクト番号から始められるようにです
// 1 つの署名のあとの更新を DocMDP 風のポリシーで検査する。
// kind は深刻度順に並ぶので、各ポリシーレベルは天井になる:
// P 1 はハウスキーピング、検証マテリアル、タイムスタンプ(0..2)を許容し、
// P 2 は署名とフォーム入力(..4)を、P 3 は注釈(..5)を追加で許容する。
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
Ceiling: array[1..3] of Integer = (2, 4, 5);
var
Signed, Count, I, Kind: Integer;
begin
Result := False;
if (Level < 1) or (Level > 3) then
Exit;
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Exit; // 履歴の一部が一度も見えていない
Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
if Signed < 0 then
Exit; // リビジョン境界に載っていない、その署名フィールドが
// 存在しないか、ファイルが読めないかのどれか
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
for I := Signed + 1 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind = 7) or (Kind > Ceiling[Level]) then
Exit; // 7 はすべての天井を超える。読み手向けに明記
end;
Result := True;
end;
天井は ISO 32000-2 の §12.8.2.2(Table 257)に従います。P 1 は無変更、2 はフォーム入力と署名を許し、3 は注釈を加え、DSS データやドキュメントタイムスタンプだけを足す増分更新はどのレベルでも変更に数えません。ISO 32000-1 の §12.8.2.2 は同じ 3 つの値をこの免除なしに定義します。ポリシーが証明書発行者のでなく自分のものなら、ループは自分で書きましょう。ファイルがどの P を帯びていようと注釈付き契約書を拒む受入規則などがその例です。ポリシーをファイルから取るべきときは、同じクラスの GetSignatureModificationVerdict が認証レベルを読み、FieldMDP ロック済みフォームフィールドへの改ざん検出で述べたフィールド単位のロックを適用し、境界外のケースには 5 を返します。どちらも、手元に取っておいた文書のコピーと突き合わせて変更を分類する方法とは別の問いに答えます。revision map に必要なのは、目の前のファイルだけです
追記も同じチェーンに従います。増分更新の trailer は、前の cross-reference セクションの位置を示す /Prev エントリを運ばなければなりません(§7.5.6)。全文スキャンからの cross-reference テーブル再構築でしか開けなかった文書には、示すべき位置がありません。AppendToFile はそれに対して 0 を返し、対象ファイルには一切触れません。全文を保存してきれいなコピーに署名するほうが賢明です。全体書き換えは既存の署名を引き継ぎません
署名済み契約書のアーカイブでは、新しいものを追記する前に、署名フィールドごとに 1 回 UpdatesWithinPolicy を走らせましょう。後続のリビジョンが kind 7 と読めるファイルは、読めないリビジョンを別の更新の下に埋めるどんな PDFlibPas の署名や長期検証の工程より先に、人の目へ届くべきです