技術記事

PDFium Component:DER長オクテットを逆にたどらない

PDFium Componentは、入れ子になったCMS構造の開始位置を内容長から求めます。長さオクテットを逆方向にたどることは決してしません。内容の直前のバイトは最後の長さオクテットであり、その前にいくつあるかについて何も語らないからです。FPdfCms.pasのCmsHeaderStartは代わりにContentLenからヘッダー長を導出し、DERはそれを正確にします。これが、証明書セットが127バイトを超えるあらゆるCMSをAddSignatureTimestampToCmsが壊さずに済む理由です

対象となるのはPAdES B-Tへの昇格です。ETSI EN 319 122-1の5.3項がOID 1.2.840.113549.1.9.16.2.14のもとで定義する署名タイムスタンプ属性は、RFC 5652の5.3項で説明されるSignerInfoのunsignedAttrsに入る必要があり、定義上、署名値が存在した後でしか追加できません。タイムスタンプトークンはその値に対して計算されるからです。つまり、トークンが到着する時点でCMSはすでに構築され、すでに署名されています。属性を1つ追加するとSignerInfoの長さが変わり、signerInfos SETの長さが変わり、SignedData、[0] EXPLICITラッパー、外側のContentInfoの長さが変わります。囲んでいるすべてのヘッダーを出力し直す必要があり、その経路上にないものはすべてバイト単位で持ち越す必要があります。B-LTとB-LTAの解説ではトークンが何をもたらすかを扱っています。この記事は、再構築が間違え続けていた証明書セットの前の4バイトについてです

タイムスタンプの追加に兄弟要素のタグオフセットが必要な理由

再構築がsignerInfos SETの4つの兄弟要素をそのまま再利用し、リーダーはそれらの内容の位置を報告するのであってタグの位置ではないからです。TDerReader.ReadTlvはタグバイト、内容オフセット、内容長、次のTLVのオフセットを返します。構造へ降りていくには正しいインターフェースですが、要素全体をコピーするにはそのタグが座っているオクテットが必要で、呼び出し側が持っているのはContentOffsだけです。CmsSliceTlvはその隙間を埋めるために存在します。内容オフセットと長さを与えると、タグ、長さオクテット、内容を1つのバッファとして返し、AddSignatureTimestampToCmsはそれをcontentType OID、version INTEGER、digestAlgorithms SET、encapContentInfo SEQUENCE、そして存在すればcertificates [0]セットに対して呼びます

// AddSignatureTimestampToCmsの中:降りて、兄弟要素をそのままスライスする
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// 任意の certificates [0]
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // タグ + 長さオクテット + 内容
  R.Position:= CN;
end;

この5つのスライスのうち4つはごく小さいものです。11バイトのOID、3バイトのINTEGER、17バイトのダイジェストアルゴリズムセット、13バイトのデタッチドencapContentInfoです。証明書セットだけが署名者証明書とそのチェーンを運び、本物のX.509証明書は最低でも数百バイトになります。したがって証明書セットは、長さオクテットが長形式になる唯一のスライスであり、古いヘルパーが位置を特定できなかったスライスでした

PAdES B-Tのタイムスタンプ追加がCMS内で再構築するもの:CmsSliceTlvはcontentType、version、digestAlgorithms、encapContentInfo、証明書セットをバイト単位でコピーします。証明書セットは短形式の長さを離れるほど長い唯一のスライスで、SignerInfoからContentInfoまでの囲んでいるすべてのヘッダーが出力し直されます
署名対象部分が構造的に手つかずなのは、SignerInfoの先頭から署名のOCTET STRINGまでがそのままコピーされるからです。そのためsignedAttrsを再ダイジェストする検証器は、タイムスタンプが入る前後で同一のバイトを見ます

DERの長さオクテットを逆にたどれない理由

長さオクテットの個数がその最初の1つに格納されており、内容から逆方向に読むと最後の1つに先に出会うからです。X.690の8.1.3.4項は短形式を定義します。1オクテットで、ビット8がクリア、ビット7から1が0から127の長さを保持します。8.1.3.5項は長形式を定義します。ビット8がセットされた先頭オクテットのビット7から1が後続オクテットの数を示し、それに続くオクテットが符号なしビッグエンディアン整数として長さを運びます。このルールには後続オクテットを後続と印付けるものが何もありません。そのビット8は他のビットと同じ大きさのビットであり、Buf[ContentOffs- 1]の最上位ビットを調べる逆方向の走査は、データビットを調べてからその下位7ビットを個数として読んでいることになります

// 内容オフセットだけを与えられた古いヘルパー
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // 最後の長さオクテットに着地する
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // 最初の1つにしか意味がない
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// 1500バイトの証明書セットのヘッダー:A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> ビット8がセット、$DC and $7F= 92
//   Result= ContentOffs- 94     (タグはContentOffs- 4にある)

1500バイトの証明書を保持する証明書セットのヘッダーA0 82 05 DCを取ります。走査はDCに着地し、最上位ビットがセットされているのを見て、下位7ビットから92を取り出し、タグが内容の94バイト前にあると報告します。実際には4バイト前です。BuildSignedDataが構築したSignedDataでは、証明書セットの内容はCMSの先頭から数十バイトの位置にしかなく、計算されたオフセットは早すぎるどころか負になりました。古いコードはContentOffs- 1がゼロを下回ることだけをガードしており、最終結果はガードしていませんでした。CmsSliceTlvは要素より90バイト余分に長いスライスを、バッファより前から取ってしまい、再構築されたSignedDataは証明書セットのあるべき場所にそのスライスを抱えることになりました。3オクテットの長さで最後のオクテットが$80を下回る場合、たとえばA0 82 05 10は逆の方向で失敗します。走査はそれを短形式のオクテットと見なし、スライスを05から、2バイト遅れて長さオクテットの内側から始め、タグは一切含まれません。結果はどちらでも誤りで、違うのは方向だけでした

DERの長さオクテットを逆にたどれない理由:A0 82 05 DCを末尾から読むと最後のオクテットDCに着地し、そのセットされた最上位ビットが92という偽の個数を与えてタグを94オクテット早く配置します。一方A0 82 05 10は逆方向に失敗し、スライスを長さオクテットの内側で2オクテット遅く開始します
古いCmsHeaderStartは最終結果ではなく途中の減算をガードしていたため、スライスはバッファより前から始まることさえあり、再構築されたSignedDataは証明書セットのあるべき場所にそのスライスを抱えていました

DERが保証する何が前向きの導出を正確にするのか

DERは、長さのエンコーディングが長さの純粋な関数であることを保証します。X.690の10.1項はDERを定形形式に制限し、最小のオクテット数を要求します。これによりBERが許す2つの自由、不定形式と、長形式の長さを先頭のゼロオクテットで埋めることが排除されます。このルールのもとでは、128未満の内容長はちょうど1つの長さオクテットを持ち、それ以外の長さは、先頭オクテット1つと、その長さが有効バイトとして必要とする数だけの後続オクテットを持ちます。CmsHeaderStartの呼び出し側はすでにContentLenを保持しています。ReadTlvがたった今それを返したからで、ヘッダー長はバッファの1バイトも見ずに計算できます

DERが保証する前向きの導出:内容長127はA0 7F、128はA0 81 80、255はA0 81 FF、256はA0 82 01 00、1500はA0 82 05 DCとして符号化されるため、ヘッダー長はContentLenだけで決まり、TryReadTlvAtが非最小のBER形式をすでにすべて拒否しています
32バイトと64バイトの証明書で作ったフィクスチャは短形式の内側にとどまり、逆方向の走査が誤った理由で正しく答える領域でした。だからこそ境界のテストスイートは127、128、255、256バイトをまたぐようになりました
// 出荷されたヘルパー:内容長からヘッダーを導出する
// X.690 10.1では長さオクテットはContentLenの関数である
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // 短形式、X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // 先頭オクテット、X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // 有効バイトごとに1つ
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

これを単に妥当なだけでなく安全にしている詳細が2つあります。1つ目、入力がDERであるという前提は上流で強制されます。ReadTlvの土台であるTDerReader.TryReadTlvAtは、不定形式を拒否し、最初の後続オクテットがゼロである長形式の長さを拒否し、$80を下回る単一の後続オクテットを拒否します。CmsSliceTlvに届くTLVはすでにこれらのチェックを通過しているため、BER風の非最小の長さが導出に到達して嘘をつかせることはありません。2つ目、負の結果に対するフォールバックが、途中の値ではなく本当の答えをガードするようになりました。リーダーはタグオフセットを最初から知っていたことは述べておく価値があります。TDerTlvはOffsetとHeaderLengthの両方を運んでおり、4出力パラメーターのReadTlvインターフェースだけがそれらを落としています。それらを返すのが長期的にはよりきれいなインターフェースですが、出荷された修正はそのインターフェースをそのまま保ち、ヘルパー自体を正しくしています

タイムスタンプのテストが不具合を抱えたまま通った理由

すべてのフィクスチャ証明書が短形式を使うのに十分短く、逆方向の走査はまさにそのケースでは正しいからです。Tests.PadesTimestamp.pasは署名者証明書を、あるテストではSetLength(SignerCertDer, 32)で、別のテストでは64で構築し、バイトのランプで埋めています。32バイトの証明書セットはA0 20、64バイトのものはA0 40として符号化され、いずれも長さオクテットは1つです。内容から逆方向にたどるとその1つのオクテットに着地し、最初で唯一の長さオクテットなので最上位ビットはクリアで、ヘルパーは誤った理由で正しく答えます。1414件のテストスイートは緑で、タイムスタンプ付きのCMSは解析され、ステージ1の検証器はB-Tを報告し、それらのチェックはすべて、実際の文書が決して含まないであろう証明書セットに対して実行されていました

一般的なルールのほうが役に立ちます。コード経路が長さのエンコーディングに依存するなら、フィクスチャはそのエンコーディング境界をまたぐ必要があり、DERではそれは127バイトを超える内容、つまり長形式を強制することを意味し、理想的には255バイトも超えて2つ目の後続オクテットを強制することも意味します。同じ規律は、自己検証がDERの逸脱を見られなかったあのレビューのもう1つのケースにも当てはまります。signedAttrs内のソートされていないSET OFは、構造的に同じ理由で同一起源のラウンドトリップには見えませんでした。テストが行使していたのは、誤ったコードと正しいコードが一致する入力だけだったのです。下のスケッチはスライスヘルパーを直接呼ぶため、テストビルド向けにFPdfCms.pasからエクスポートする必要があります。同じ境界には、各サイズのチェーン証明書をBuildSignedDataに渡し、タイムスタンプ付きの結果を再解析するという公開インターフェース経由でも到達できます

// 境界を固定する:長形式のヘッダーを通すスライスはタグから始まらなければならない
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // 内容はヘッダーの直後から始まる。スライスはTLV全体でなければならない
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

再構築がまだ線を引く場所

AddSignatureTimestampToCmsはBuildSignedDataが出力するCMS向けに書かれており、その限界はそこから従います。走査は単一のSignerInfoを期待し、それだけを出力し直すため、外部のマルチ署名者CMSは署名者1つで戻ってきます。任意のcertificates [0]セットは認識しますがcrls [1]セットは認識せず、それを持つCMSは静かに誤ったスライスをするのではなく、signerInfos SET expected例外で派手に失敗します。新しいunsignedAttrsは属性1つを保持するため、X.690の11.6項のSET OF順序ルールは自明に満たされ、ソートは不要です。そして署名対象部分は構造的に手つかずです。署名のOCTET STRINGまでのSignerInfoの先頭部分がそのままコピーされるため、signedAttrsを再ダイジェストする検証器は、タイムスタンプが追加される前後で同じバイトを見ます。それでも文書が拒否される場合、原因はたいてい別のところにあり、独自のチェックリストに値します

DERリーダー、ライター、CMSビルダー、そしてこのタイムスタンプ注入はすべて、PDFium DelphiコンポーネントにPascalソースとして同梱されています。この形の不具合はその論拠になります。再構築したSignedDataが90バイト長すぎる形で出てきたとき、読みたいのはブラックボックスからのスタックトレースではなく、スライスを切ったヘルパーと、それが読み違えたX.690の条項なのです