技術記事

PDFium ComponentのsignedAttrs:DER SET OFソート

PDFium Componentは、TDerWriter.SetOfSortedを経由してCMSのsignedAttrsブロックを構築します。X.690の11.6節が、SET OFのメンバーをそれぞれ自身のDERエンコーディングの昇順で出力するよう要求しているからです。代わりに構築順で組み立てると、自分のバリデータは受け入れるのに、すべてのサードパーティPAdESバリデータが拒否する署名ができあがります

この最後の一文こそ、この欠陥が完全なリグレッションスイートをすり抜けた理由のすべてです。署名パスも検証パスもカバーされ、ラウンドトリップは全プラットフォームでグリーンでしたが、出力は依然として有効なDERではありませんでした。自分のライターで署名し、自分のリーダーで検証するテストは、エンコーディング規則からの逸脱を検出できません。両側が同じ逸脱を共有しているからです

X.690 11.6節が実際に要求していること

SET OF値のメンバーはソートされなければならず、ソートキーは各メンバーの完全なDERエンコーディングを、符号なしオクテット文字列として比較したものです。一方が他方のプレフィックスである場合、短い方が先に来ます。これは提案ではなく正規の制約です。DERは与えられた値に対して正確に1つのエンコーディングしか認めないため、ソートされていないSET OFは同じ値の別の順序のエンコーディングではなく、そもそもDERエンコーディングではありません。ASN.1タグはTLVの先頭に来るため、比較は通常最初の2オクテット内で決着します。そしてCMSの属性リストのすべてのメンバーは30、つまりSEQUENCEタグで始まるため、タイブレークは長さオクテットで決まります。したがって要求される順序は、属性をサイズ順に並べたように見えます。16進ダンプを見つめながら順序が恣意的に見える理由に首をかしげるときに、これを知っておく価値があります

PDFium Component記事の図:X.690 11.6節がCMS signedAttrsのSET OFをソートする仕組みを示します。BuildSignedAttrsはcontent-type、message-digest、signing-certificate-v2、cms-algorithm-protectionを構築順に追加しますが、準拠したDERは4つのメンバーを完全なエンコーディングで並べ、30 18、30 2D、30 2F、30 37の順になります
最初の署名から構築順とエンコード順が食い違います。4つのメンバーすべてがSEQUENCEタグ30を持ち、長さオクテットが比較を決めるからです

最小構成の属性セットがすでに順序崩れになる理由

生成する最初の署名から、自然な構築順とエンコード順が食い違うからです。オプション属性がまったく関与しない状態で。FPdfCms.pas内のBuildSignedAttrsは、content-type、次にmessage-digest、その次にsigning-certificate-v2、最後にcms-algorithm-protectionを追加します。SHA-256ダイジェストとRSA署名アルゴリズムの場合、4つの属性TLVは次のバイトで始まります

// BuildSignedAttrsが生成する構築順
//   content-type              30 18   (24 content octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// X.690 11.6が要求するDER順
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

4つの属性のうち2つが動きます。30 2Dのcms-algorithm-protectionは30 37のsigning-certificate-v2より前に来なければならず、30 2Fのmessage-digestより前にも来なければなりません。つまり、PDFium Componentが出力できる最小の準拠PAdES B-B属性セットは、signing-time、commitment-type-indication、signature-policy-identifierを誰も追加しないうちから、旧コードの下で既に順序崩れでした。このバグが発動しない設定は、最初から存在しませんでした

外部バリデータがすべて失敗するのに自前のテストが通る理由

2つの系統のバリデータが、異なるバイトに対してメッセージダイジェストを計算するからです。RFC 5652の5.4節は、署名操作への入力がSignedAttributes値のDERエンコーディングであり、IMPLICITの[0]タグがSETタグ31に置き換えられると述べています。そしてすべてを決めるのはDERエンコーディングであって、受け取ったオクテットではないという文言です。CryptVerifyDetachedMessageSignature経由で到達するWindowsプラットフォームのバリデータは、受け取ったオクテットを取り、先頭タグを交換して、到着したままの状態でハッシュします。したがって順序はまったく関与せず、順序崩れのSET OFでも署名者が計算したものと正確に同じダイジェストになります。欧州で広く使われているバリデータが構築されているOpenSSLとBouncyCastleは、属性セットを構造体にパースし、ダイジェストする前に再エンコードします。再エンコードはソートを適用するため、ダイジェストは署名されたものと一致しなくなり、署名は誤エンコーディングではなく壊れていると報告されます。その結果、比較関数の中に住んでいる欠陥を探すために、鍵素材と証明書チェーンを探し回ることになります

ソートされたSET OFバグの背後にあるバリデータの分岐を示すPDFium Componentの図:署名者はSETタグ31付きのDERエンコーディングをハッシュし、WindowsのCryptVerifyDetachedMessageSignature経路は受け取ったオクテットをダイジェストするため順序は一切関係なく順序崩れの署名も通過します。一方、OpenSSLとBouncyCastleは再エンコードしてX.690のソートを適用し、署名を壊れていると報告します
CryptVerifyDetachedMessageSignatureは到着したままのオクテットをダイジェストするため順序崩れのセットでも検証が通りますが、OpenSSLとBouncyCastleはダイジェスト前に再エンコードし、署名を誤エンコーディングではなく壊れていると報告します

この1つのバグを越えて一般化しましょう。そこが割高な部分です。同一ソースのラウンドトリップは、コードの両半分が共有する相互運用性仕様からのあらゆる逸脱に対して免疫があります。自分のライターとリーダーが互いに一致することを証明するだけであり、それは誰も気にしない性質です。プロセスからワイヤフォーマットとして出ていくものについては、バイトを仕様に固定するか、自分の出力に対して外部の実装を走らせるリグレッションだけが意味を持ちます。OpenSSL CMS検証バックエンドがここで地位を得ているのは、移植性を超えた2つ目の理由もあります。自分のDERに対する真に独立したリーダーだからです

修正と、それが既に存在していた場所

DERライターには、ずっと前からソート版が存在していました。FPdfAsn1.pasのTDerWriterは、事前に連結されたコンテンツバッファをSETタグで包み、渡された順序をそのまま保つSetOfと、メンバーを個別にTDerByteArraysとして受け取り、包む前にエンコーディングでマージソートするSetOfSortedの両方を公開しています。FPdfMac.pasのPDF MACコードは、すでにSetOfSortedで認証属性セットを構築していました。CMS署名者だけがそうしていなかったのです。メンバーをラップ呼び出しの直前まで配列のまま保持することが、修正の構造的な半分です。属性が1つのバッファに連結された時点で順序情報は消え、下流のどんな注意もそれを復元できないからです。SET OFに対してSetOf(Content: TBytes)しか提供しないライターAPIは、このバグを招くAPIです。配列を受け取るオーバーロードこそが、正しいことを表現可能にします

PDFium ComponentのTDerWriter APIペアの図:SetOfは事前に連結されたTBytesバッファを包み、渡されたメンバー順をそのまま保つため、DERではない出力を生みます。一方SetOfSortedはメンバーをTDerByteArrays配列として保持し、完全なエンコーディングでマージソートして、準拠したSET OFを包みます
属性が1つのバッファに連結された時点で順序情報は消えるため、配列を受け取るSetOfSortedオーバーロードが、エンコードの時点で正しいことを表現可能にするのです
// 修正前:AddAttrが追加した順序をメンバーがそのまま保持する
Result:= W.SetOf(ConcatMany(AttrList));

// 修正後:エンコードの時点でX.690 11.6の順序を適用する
Result:= W.SetOfSorted(AttrList);

SET OFはソートし、SEQUENCE OFはソートしない

これが最もよく混同される区別で、逆に理解するのは無視するのと同じくらい有害です。SEQUENCE OFは順序付きコレクションです。その順序は値の一部であり、決してソートしてはいけません。SET OFは値としては順序を持たないため、DERはエンコーディングを一意にするために正規の順序を課します。SEQUENCE OFをソートするとデータが壊れ、SET OFをソートせずに置くとDERではないものが生まれます

  • SET OF、ソート必須:SignerInfo内のSignedAttributesとUnsignedAttributes、digestAlgorithmsセット、SignedData内のcertificatesとcrlsセット、signerInfosセット、個々のAttribute内の値セット、そしてX.501 Nameの各RelativeDistinguishedName内の属性セット
  • SEQUENCE OF、決してソートしない:Nameを構成するRDNリスト、SigningCertificateV2のcertsフィールド、拡張リスト、そしてその中のgeneral-nameリスト

RDNは、1つ上の階層のSEQUENCE OFの中に座るSET OFです。したがってdistinguished nameは、2レベルの入れ子の中に両方の規則を含んでいます。この構造は、単一メンバーのセットがなぜこんなに寛容なのかも説明します。要素が1つのセットは自明にソートされており、BuildSignedAttrs内でW.SetOfを使って構築された属性値セットが、書かれたとおり正しく変更不要だった理由もそこにあります。このバグは、セットが実際に2つ以上のメンバーを持つ場所にだけ住んでいて、複数値のRDNはまれなため、ほとんどの名前エンコーダは同じ過ちを何年も犯しながら逃げ切れます

この再発を防ぐ方法

判定ではなくバイトに対してアサートしましょう。最も安価で持続的なチェックは、自分の出力を歩く自己監査です。出力されたsignedAttrsをパースし、ライターが使うのと同じ符号なしオクテット文字列比較で各メンバーを前のものと比較し、どのペアでも順序崩れならビルドを失敗させます。マイクロ秒で実行され、外部ライブラリも不要で、検証のラウンドトリップと違って、仕様が実際に述べている性質をテストします

// SET OFのメンバーを歩いてX.690 11.6の順序を確認する
// MembersはSetOfSortedに渡したリスト、または出力された
// 31 xx / A0 xx ブロックを再パースした結果
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// 署名テストでは「自前のバリデータが有効と言った」で止まらない
// 署名者がハッシュしたバイトを固定する
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

2つの限界を率直に述べておきます。signedAttrsをソートしても、それだけで署名が相互運用可能になるわけではありません。コンテナ内の他のすべても持ちこたえなければならず、それ以外は健全なPAdES署名をバリデータが拒否する理由は、独自の考察を埋め尽くすほど多様だからです。また、ソートされたSET OFは、読むものではなく書くものの性質です。PDFium Componentは、signedAttrsがソートされずに届く署名も依然として受け入れなければなりません。そういうファイルは実在し、RFC 5652の5.4節はバリデータに拒否ではなく再エンコードを指示しているからです。いつもの通り、出力に厳格、入力に寛容です。DERライター、CMSビルダー、そしてPAdES B-Bのウォークスルーで説明されている署名パスは、すべて読みやすいPascalソースとしてPDFium Delphi componentに同梱されています。これは署名ライブラリでソースを配布する意味そのものです。ヨーロッパのどこかのバリデータがあなたの文書に異議を唱えたとき、チケットを切って待つのではなく、コードが出力した正確なバイトと、それらが違反している正確な条項を読むことができます