技術記事

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

A0 82 05 DC の 4 バイトは、1500 バイトの内容を運ぶコンテキスト固有の [0] 要素を開きます。左から右へ読めば曖昧さはありません。タグ A0、さらに 2 つの続きを告げる最初の長さオクテット 82、それから 05 DC。最初の内容バイトから右から左へ読むと、最初に出会うオクテットは DC です。その最上位ビットは立ち、下位 7 ビットは 92 を告げるので、それを信じるヘルパーは、内容の 94 バイト前にタグがあると報告します。本当の距離は 4 です

誰も意図的には逆方向へ歩きません。起こるのは、ほとんどの DER リーダーが各要素の内容オフセットと長さを手渡すからです。それは要素の中へ降りるのにちょうど必要なものですが、兄弟要素を無変更でコピーするにはタグのオフセットが要ります。内容オフセットからの引き算は 2 行の仕事に見え、すべての長さが 1 オクテットに収まる限り正しいままです。現実の証明書セットの長さは 1 つには決して収まりません

署名タイムスタンプは署名済み CMS に何をするか

PAdES B-T タイムスタンプの追加は、CMS のすべての外側のヘッダーの再符号化を強制する一方、5 つの兄弟要素はバイト単位でそのまま運ばれなければなりません。ETSI EN 319 122-1 の 5.3 節が OID 1.2.840.113549.1.9.16.2.14 の下で定義する signature-time-stamp 属性は、RFC 5652 の 5.3 節の SignerInfo の unsignedAttrs に住み、その中の RFC 3161 トークンは署名値の上で計算されます。だから CMS は、トークンが TSA から到着するとき、すでに組み立てられ、すでに署名済みです。1 つの属性の追加が SignerInfo を伸ばし、それが signerInfos SET を伸ばし、次に SignedData、次に [0] EXPLICIT ラッパー、次に外側の ContentInfo を伸ばします

PDFium Component では、その連鎖は FPdfCms.pas と FPdfTsa.pas の 3 つのエクスポートされた呼び出しです。SignPadesBytes は、TPadesSignOptions.Level が plB_T 以上で TsaUrl が設定されているとき、それらをあなたの代わりに実行し、空の TsaUrl で B-T レベルを要求されたときは、素の B-B 署名を書く代わりに EPadesCrypto を上げます。CMS を BuildSignedData で自分で組み立てるなら、連鎖は次のようになります

// CmsDer は BuildSignedData が返した ContentInfo を保持する。
// SignPadesBytes は Options.Level >= plB_T のとき同じ 3 工程を実行する
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // TSA の messageImprint は署名値オクテットの上で計算される
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // SignerInfo から ContentInfo までを再符号化し、兄弟は無変更でコピーする
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms は 5 つの要素を手つかずのまま運びます。contentType OID、SignedData のバージョン INTEGER、digestAlgorithms SET、encapContentInfo SEQUENCE、そして存在するときは certificates [0] セットです。BuildSignedData が SHA-256 で出力する CMS では、そのうち 4 つは小さなものです。OID に 11 バイト、バージョンに 3、ダイジェストアルゴリズムセットに 17、detached な encapContentInfo に 13。証明書セットは署名者証明書と、渡されたすべてのチェーン証明書を保持します。現実の X.509 証明書は数百バイトに及びます。長さオクテットが常に長形式を使う唯一のスライスがこれです

CMS に PAdES B-T タイムスタンプを足すと何が再構築されるか: AddSignatureTimestampToCms は contentType、バージョン、digestAlgorithms、encapContentInfo、証明書セットをバイト単位でコピーし、証明書セットは短い長さ形式を外れるのに十分な長さの唯一のスライスで、SignerInfo から ContentInfo までのすべての外側のヘッダーが再出力される
署名部分は構造上の理由で手つかずです。署名 OCTET STRING までの SignerInfo プレフィックスは一字一句そのままコピーされるので、signedAttrs を再ダイジェストするバリデータは、タイムスタンプが着地する前後で同一のバイトを見ます

DER の長さオクテットはなぜ逆方向へ歩けないのか

長さオクテットの個数はその最初のものに格納されており、逆方向の歩みは最初に最後のものに出会います。X.690 の 8.1.3.4 節が短形式を定義します。1 オクテット、ビット 8 はクリア、ビット 7 から 1 が 0 から 127 の長さを保持します。8.1.3.5 節が長形式を定義します。その最初のオクテットはビット 8 が立ち、ビット 7 から 1 が、長さを符号なしビッグエンディアン整数として保持する後続オクテットの個数を数えます。後続オクテットを後続として印付けるものは何もありません。そのビット 8 は、他の 7 ビットと同じ大きさのビットです。だから Buf[ContentOffs- 1] の最上位ビットを検査するヘルパーは、データを検査してから、データを個数として読んでいるのです

// 内容オフセットだけを与えられた、誘惑的なヘルパー。これを出荷してはならない
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // 最後の長さオクテットに着地する
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // 最初のものについてのみ意味を持つ
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 bytes): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, the tag is at ContentOffs- 4
// A0 82 05 10 (1296 bytes): $10 has bit 8 clear, read as short form
//   Result= ContentOffs- 2, which points at 05 inside the length octets

両方の失敗方向は、ありふれたサイズで姿を現します。1500 バイトの証明書セットはヘッダーを DC で終え、ヘルパーはスライスを 90 バイト早く始めます。1296 バイトのセットは 10 で終わり、ヘルパーはそれを短形式の長さと取り違え、スライスは 2 バイト遅く、タグなしで始まります。あるチェーンがどちらのケースに着地するかは、総長の下位バイト以外の何ものでもありません

早いケースはバッファの正面から飛び出すこともできます。BuildSignedData が敷くように、すべての外側の長さが 2 オクテットを取る CMS では、証明書セットの内容は 60 バイト目から始まります。ContentInfo ヘッダーに 4、OID に 11、[0] と SignedData のヘッダーに各 4、それから小さな兄弟に 3、17、13、証明書セットのヘッダーに 4。60 から 94 を引くと、ヘルパーは -34 を返します。その唯一のガードは ContentOffs- 1 の上に座るので、負の結果はまっすぐコピーへ流れ込みます

DER の長さオクテットを逆方向へ歩けない理由: A0 82 05 DC を終端から読むと最後のオクテット DC に着地し、立った上位ビットが 92 という嘘の個数を生みタグを 94 オクテット早く置く一方、A0 82 05 10 は逆のやり方で失敗し、スライスを長さオクテットの中の 2 オクテット遅く始める
中間の引き算ではなく最終結果を守る逆方向の歩みは、スライスをバッファの前から始めることさえあり、再構築された SignedData は、証明書セットがあるべき場所にそのスライスを乗せたままになります

X.690 の 10.1 節が確定させること

DER は長さの符号化を長さの純関数にします。X.690 の 10.1 節は DER を確定形式に限り、最小のオクテット数を要求します。これは BER が許す 2 つの自由、不定形式と、先行ゼロオクテットで詰められた長形式の長さを取り除きます。その規則の下では、128 未満の内容長は常にちょうど 1 つの長さオクテットを取ります。それ以外の長さは、1 つの最初のオクテットに、値の意味のあるバイト数ちょうどの後続オクテットを加えます。だから 128 は 81 80 になります。256 ではヘッダーは 82 01 00 に育ち、65536 は 83 01 00 00 を要します。内容長をすでに保持しているコードは、バッファの 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 形式をすでに拒んでいる
128 バイト未満の内容は短形式の中にとどまり、そこでは逆方向の歩みが間違った理由で正しく答えるので、境界テストは 127、128、255、256 バイトをまたがなければなりません

導出は、入力が本当に DER だという主張と同じ程度にしか良くありません。TDerReader.TryReadTlvAt は、スライスが起こる前にその主張を強制します。最初の長さオクテットが $80(不定形式)であること、最初の後続オクテットがゼロの長形式の長さ、$80 未満の 1 つの後続オクテット(短形式であるべき)、4 つを超える後続オクテットを拒みます。ReadTlv は TryReadTlvAt の上に組み立てられているので、AddSignatureTimestampToCms が歩むすべての要素は、すでにそれらの検査を通っています。BER 流の詰められた長さは、算術に決して届きません

AddSignatureTimestampToCms の内部では、各兄弟のタグオフセットはこのやり方で、内容オフセットと内容長から導かれます。導かれた開始がバッファの先頭より前に落ちるとき、つまりオフセットが一緒に属し得ないとき、procedure は CMS: TLV header precedes the buffer start というメッセージの例外を上げ、CMS をまったく生み出しません。それでもパースできる不正な再構築は、オフセットが一致しなくなった場所での例外より、はるかに追跡が困難です

導出はもう 1 つの仮定を運びます。識別子が 1 つのオクテットを取るということです。X.690 はタグ番号 31 以上に対するマルチオクテット識別子を許し、TryReadTlvAt はそれらをパースします。CMS の SignedData は 1 つも使いません。タグは universal の SEQUENCE、SET、INTEGER、OID に、コンテキスト固有の [0] と [1] を加えたものであり、だからタイムスタンプの再構築が触れるすべての要素で仮定は成立します

自分の DER コードで兄弟要素をコピーする

TDerReader に対してコードを書くなら、導出をまるごと飛ばしてください。PeekTlv と ReadNextTlv は TDerTlv レコードを埋めます。その Offset はすでにタグを指し、HeaderLength はタグと長さオクテットの測られたサイズであり、EncodedBytes は Offset から NextOffset までの要素を返します。その経路は、どんな長さの算術も復元できないマルチオクテット識別子にも対処します。4 つの out パラメータを持つ ReadTlv は降りるのに便利です。ただし内容側だけを報告するので、内容が要るときにはそれを、要素全体が要るときにはレコード形に手を伸ばしてください

// SignedData のすべての子を完全な TLV として: タグ、長さオクテット、内容。
// TDerTlv.Offset はタグを指すので、ヘッダーの算術は関与しない
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // e.g. A0 82 05 DC ...
  end;
end;

他の API が内容オフセットと長さしかくれないケースのために、前方向の導出は短いものです。次の節で境界検査とともに示す DerHeaderStart はあなた自身のコードです。10.1 節の規則を写し、一致し得ないオフセットには -1 を返すので、呼び出し側は、もっともらしく見える開始バイトではなく、拒める値を手にします

短いテスト証明書が過ちを隠すのはなぜか

小さなプレースホルダー証明書は証明書セットを短形式に保ち、逆方向の歩みは正確にそのケースで正しいのです。32 バイトのセットは A0 20 と符号化され、64 バイトのものは A0 40 です。内容から逆方向へ歩むと、あの 1 つの長さオクテットに着地し、それが最初で唯一のものなので、最上位ビットはクリアです。正しい答え、間違った理由。それほど小さな証明書の上に組み立てられたタイムスタンプテストは、結果を再パースし、属性を見つけ、B-T を報告しながら、現実的なサイズの証明書セットをスライスに通したことが一度もない、ということが起こります

長さの符号化に依存するすべてのコード経路には、符号化の境界をまたぐテスト入力が要ります。DER では、それは 128 バイト以上の内容、つまり長形式を強制するものと、256 バイト以上の内容、つまり 2 つ目の後続オクテットを強制するものを意味します。下の検査は DerHeaderStart を、リーダーがパース中に測ったヘッダーと比較します。両方の境界と、冒頭の 1500 バイトのケースで。signedAttrs のソートされていない SET OFが、同じ構造的な理由で同系のラウンドトリップをすり抜けます。テストは、間違ったコードと正しいコードが一致する入力だけを与えているのです

// 内容オフセットと長さだけを報告する API のための、あなた自身のヘルパー。
// 1 オクテットの識別子を仮定する。CMS SignedData のすべてのタグで真
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // short form, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // long form, X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // 意味のあるバイトごとに 1 つ
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // 一貫しないオフセット: 呼び出し側に上げさせる
end;

// 境界検査(自分のテストコードとして書かれる): 導出されたタグオフセットは、
// ヘッダーのパース中に TDerReader が測ったオフセットと等しくなければならない
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

同じ境界は、エクスポートされた関数を通して端から端まで届きます。合計サイズが証明書セットをちょうど 128 と 256 バイトのすぐ下とすぐ上に置くチェーン証明書を BuildSignedData に渡し、各結果に AddSignatureTimestampToCms を実行し、それから出力を SignedDataChildren で歩み、タイムスタンプを足す前の CMS の証明書セット要素とバイト単位で比較してください

AddSignatureTimestampToCms が受け入れるもの

AddSignatureTimestampToCms は、BuildSignedData が出力する CMS のために書かれています。1 つの SignerInfo、任意の certificates [0] セット、crls [1] なし、そして unsignedAttrs はまだありません。1 つを超える SignerInfo を保持する signerInfos SET を与えると、最初の署名者を保って残りを落とす代わりに、例外を上げます。crls [1] セットを運ぶ CMS は CMS: signerInfos SET expected の例外で止まります。証明書の後の要素が、歩みが探している SET ではないからです。両方の拒否は、何かが CmsDer に書き戻される前に起こります

新しい unsignedAttrs は正確に 1 つの属性を保持するので、X.690 の 11.6 節の SET OF 順序規則はソートなしで満たされます。バージョンから署名 OCTET STRING までの SignerInfo の署名部分は 1 つのブロックとしてコピーされるので、signedAttrs を再ダイジェストするバリデータは、トークンが足される前後で同じバイトを見ます。バリデータがそれでも文書を拒むとき、原因はたいてい別の場所に座っており、PAdES 拒否理由のチェックリストでカバーされます。トークンが時間をかけて買ってくれるもの、そして B-LT と B-LTA がその上にどう築かれるかは、RFC 3161 タイムスタンプと DSS のウォークスルーにあります

FPdfAsn1.pas の DER リーダーとライター、FPdfCms.pas の CMS ビルダーとタイムスタンプ工程は、すべて、PDFium Component for Delphi に付いてくる Pascal ソースです。再構築された SignedData がバリデータの中でパースを拒むとき、各要素を切ったコードをステップ実行し、それが引用する X.690 の節と突き合わせられます