Delphi用のPDFium Componentでデバッグしたほぼすべてのケースにおいて、検証ツールがPAdES署名を拒否する原因は次の3つのいずれかです。/ByteRange配列がゼロのプレースホルダーのままであること、/M署名時刻が正しい形式のPDF日付文字列ではないこと、あるいは増分更新によって暗号化ドキュメントから/Encryptエントリが脱落したことです。これら3つはすべて、表示やレンダリングは問題なく行えるものの、規格準拠の検証ツールが署名辞書を読み取った瞬間に失敗するファイルを生成します
この記事を書くきっかけとなったシナリオは、非常に具体的で厄介なものです。PAdES B-B署名の記事に記載されているワークフローで契約書に署名し、自前の検査コードでも署名が存在し構造的に健全であると報告され、ローカルテストはすべて合格したのに、取引先がそのファイルを検証プラットフォームにアップロードすると赤色のバツ印(エラー)が表示されてしまうという状況です。これらの不具合はいずれもページの表示内容には影響しないため、ビューアでは一切確認できません。不具合は署名辞書やファイルトレーラーの内部のみに存在しており、そこはまさに検証ツールが検査する一方で、ビューアはほとんど見ない場所なのです
なぜPDF署名のByteRangeが無効になるのですか?
ByteRangeによる拒否は、範囲の指定が微妙にずれているというよりも、4つのフィールドが入力されていないことが原因であることがほとんどです。/ByteRange [A B C D]配列は、バイトAからA+Bまでと、バイトCからC+Dまでの2つのスパンを宣言します。そして、EN 319 142-1 §6.3(要件 k)は、これら2つのスパンで、16進エンコードされた/Contents文字列を除くファイル全体をカバーすることを要求しています。具体的な数値で言えば、Aは0であり、C >= A+Bであり、C+Dはファイル長に等しく、BとCの間には正確に<...>16進文字列(ブラケット2バイト+CMSデータ1バイトにつき16進文字2文字)が収まります。検証ツールは、[0 0 0 0]を読み取ると、/Contents内のCMS構造がどれほど正しくても、署名は何もカバーしていないと判断して拒否します
この仕組みはすべての署名スタックに共通するため、動作原理を理解しておく価値があります。署名処理を実行するプログラムは、ファイル全体のレイアウトが決まるまで最終的なオフセットを知ることができないため、最初は固定幅 of ゼロプレースホルダーで配列を出力し、レイアウト決定後に値を書き戻します。バージョン2.14.1より前のPDFium Componentでは、その書き戻し処理が/Contentsの位置からプレースホルダーパターンを検索していました。しかし、辞書内において/ByteRangeは/Contentsより前に配置されているため、検索にヒットせず、3つの置換はすべて暗黙的に失敗していました。CMSダイジェストは正しい範囲で計算されていたため暗号自体は健全でしたが、その範囲の宣言がゼロのままであったため、規格準拠の検証ツールはすべてそのファイルを拒否しました。同じ辞書レイアウトを使用するPAdES B-LTAドキュメントタイムスタンプでも同様の理由で失敗していました。これは、長期B-LTおよびB-LTA署名を構築する際に重要です。バージョン2.14.1では、署名オブジェクトの開始位置から書き戻しを行うようになり、この修正は、生成されたファイルを解析し、以下の計算式をアサートする回帰テストによってカバーされています
function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
Raw: TBytes;
Text: AnsiString;
P, N: Integer;
F: array[0..3] of Int64;
begin
Raw := TFile.ReadAllBytes(FileName);
SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
P := Pos('/ByteRange', Text); // first signature only
Result := P > 0;
if not Result then Exit;
Inc(P, Length('/ByteRange'));
for N := 0 to 3 do
begin
while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
F[N] := 0;
while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
begin
F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
Inc(P);
end;
end;
// EN 319 142-1 §6.3 req k: spans cover everything except /Contents
Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
(F[2] + F[3] = Int64(Length(Raw)));
end;
20行のシンプルなRTLコードで、ライブラリ呼び出しは不要ですが、生成の時点でこの欠陥のカテゴリ全体を捉えることができます。この記事からアサーションを1つだけ残すとするなら、これを選択してください。生成された署名済み出力を自身で解析し、ファイルがプロセスから離れる前にこれら3つの等式を検証してください
なぜ検証ツールは/M署名時刻を不正な形式としてフラグを立てるのか?
厳格な検証ツールは、完全なPDF日付文字列ではない署名時刻を拒否します。特に欠落しやすいのが、D:プレフィックスとUTCオフセットマーカーの2つです。ISO 32000-1 §7.9.4は、日付フォーマットをD:YYYYMMDDHHmmSSの後にオフセット(UTCの場合はZ、それ以外の場合はUTCとの関係を示す符号付きの+HH'mm'など)が続くものと定義しています。単なる20260709143000でも寛容なリーダーなら日付として解析できますが、文法を厳密に適用する検証ツールは必須フォーマットのフィールド内に不正な形式の文字列があるとして、署名にフラグを立てます。バージョン2.14.4より前のPDFium Componentは、TPdf.SignPadesおよびSignPadesBytesの/Mエントリをまさにその裸の形式で書き込んでいましたが、2.14.4以降はD:プレフィックスとZマーカーを付与するようになり、宣言される署名時刻はD:20260709143000Zと記述されます
このフィールドに関しては、実務上2つの注意点があります。第一に、UTCで記述し、それを明記することです。オフセットマーカーのないタイムスタンプは、検証ツールにタイムゾーンの関係を推測させることになり、§7.9.4はオフセットをオプションではなくフォーマットの一部として扱います。第二に、/Mは署名者が宣言した時刻(証明ではなく単なる主張)であることを忘れないでください。検証ツールはここでフォーマットを検査するだけであり、真実かどうかは検査しません。証明可能な時刻は、PAdES B-Tレベル以上でのRFC 3161タイムスタンプによって提供されます。フィールドを正しくフォーマットすることと、それを信頼することは別の問題であり、検証ツールが強制するのは前者のフォーマットのみです
なぜSignPadesは暗号化されたPDFに対してEPadesCryptoを発生させるのか?
PDFium Componentが暗号化されたドキュメントへの署名を拒否するのは、そうしないと、規格準拠のリーダーで開いた際にファイルが破損してしまうためです。PAdES署名は増分更新として末尾に追加されますが、ISO 32000-1 §7.5.6は、更新セクションの新しいトレーラーが、/Prevを除く直前のトレーラーのすべてのエントリを保持することを要求しています。暗号化されたドキュメントでは、これに/Encryptが含まれます。これを取り除くと、最新のトレーラーはファイルが暗号化されていないと宣言することになり、規格準拠のリーダーは暗号化された本文をプレーンテキストとして解析するため、ゴミデータを取得することになります。さらに悪いことに、署名者が追加するストリームや文字列も、合法であるためにはドキュメントキーで暗号化されている必要がありますが、プレーンテキストの署名インジェクターにはそれができません。暗号化されたファイルに有効なプレーンテキストの署名を追加する方法はないため、バージョン2.14.2以降、TPdf.SignPades、SignPadesBytes、およびInjectPadesDssMarkersは、破損した、あるいは検証不可能なファイルを生成する代わりに、EPadesCryptoを発生させます
try
if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
Writeln('Signing reported failure');
except
on E: EPadesCrypto do
begin
// e.g. 'SignPadesBytes: the source document is encrypted;
// remove encryption before signing'
Writeln('Cannot sign: ', E.Message);
end;
end;
例外が発生することは正しい動作です。例外をキャッチして再試行するのではなく、その例外を前提としたワークフローを設計してください。まずオーナー権限で復号し、プレーンテキストのコピーに署名します。配信チャネルで暗号化が必要な場合は、「暗号化してから署名」と「署名してから暗号化」は異なる検証結果を持つ異なる成果物を生成することを受け入れてください。プレーンテキストのバイトに対して計算された署名は、それらのバイトが再暗号化されると無効になります。したがって、現実的な選択肢は、署名されたプレーンテキストファイルを使用するか、あるいはコードの隣にポリシー決定を文書化しておくことです
2つのバグが互いを正常と判定し合う仕組み
ByteRangeの欠陥が長期間にわたって発見されなかったのは、自前の検証ツールにも補完的な誤りがあったからであり、これこそがこの記事から学べる最も重要な教訓です。ValidatePadesComplianceのカバー範囲チェックは、2番目のスパンが正確にA+B(ギャップなし)から開始されることを要求していました。これは、ギャップ部分に/Contentsの16進文字列が保持される標準的なレイアウトを誤判定してしまいます。結果として、社内の検証ツールは仕様に準拠したレイアウトを拒否し、社内のジェネレーターはそれを生成しなかったため、エンドツーエンドの署名・検証パイプラインはたまたま合格し続けていました。それぞれのバグが、もう一方のバグを暴くはずの反例テストケースを排除していました。バージョン2.14.1では、両方の問題が同じリリースで修正され、ジェネレーターは4つのフィールドをすべて書き戻すようになり、検証ツールはC >= A+B(かつC+Dがファイル長に等しいこと)を受け入れるようになりました
根本的な開発体制の解決策は、自身が作成したものではない他の実装でクロス検証(相互検証)を行うことです。同じコードベース、同じ開発者、あるいは共通の認識を共有するジェネレーターと検証ツールは、共通の誤解にいつまでも気付けない可能性があります。独立した別の検証ツールを使用すれば、その対称性を打破できます。リリースの前に、少なくとも1つの外部適合性チェックツールで署名出力を確認し、ビルド処理の最初の防衛線として、構造的な自己チェック(署名検査の記事で説明されているTPdf.ValidatePadesなど。カバー範囲の失敗を特定の問題として報告します)を組み込んでおいてください
var
R: TPadesValidationResult;
begin
Pdf.FileName := 'contract-signed.pdf';
Pdf.Active := True;
R := Pdf.ValidatePades;
if ppeiByteRangeNotCoveringFile in R.Issues then
Writeln('ByteRange does not cover the file');
if not R.IsCompliant then
Writeln('Structural issues present: do not ship this file');
end;
署名出力が拒否された場合のチェックリスト
プラットフォームでPAdES署名が拒否された場合、証明書や信頼チェーンを疑う前に、まず簡単な構造上の原因を確認してください。/ByteRange配列を読み取り、3つの関係式(最初のフィールドが0であること、2番目のスパンが1番目のスパンの終了以降から始まっていること、2番目のスパンがファイル全体の最後で終了していること)を検証します。/Mエントリを読み取り、D:プレフィックスとオフセットマーカーを含める完全な§7.9.4日付文字列であることを確認します。署名を追加する際に、元のドキュメントが暗号化されていなかったことを確認します。使用しているスタックがエラーを吐かずに暗号化ファイルに署名してしまった場合は、その動作自体をスタックのバグとして扱ってください。これら3つのチェックはミリ秒単位で生のバイトデータを走査でき、経験上、暗号化関連の原因よりもはるかに頻繁に拒否の原因を説明できます
ここで使用されている署名、検査、および検証の呼び出し(修正されたByteRange計算、日付フォーマット、暗号化制御機能を含むSignPades、ValidatePades、およびPAdES適合性チェック)は、Delphi、C++Builder、およびLazarus用のPDFium Componentに含まれています