技術記事

PDFium ComponentとRFC 4055のPSSパラメーター符号化

末尾がA0 0F … A1 1C … 02 01 20 02 01 01というRSASSA-PSSのsignatureAlgorithmの16進ダンプは、完成品に見えます。SHA-256、MGF1、32バイトのソルト、trailerの1。厳格なデコーダーはそのパラメーターブロックを丸ごと拒否し、寛容なデコーダーはソルト長を20と読みます。どちらにしても、RSAの算術が正しい署名が無効として返ってきます。RFC 4055 §3.1が理由を説明します。RSASSA-PSS-paramsのすべてのフィールドは明示的なコンテキストタグを運ぶので、ソルトはA2の中に属し、DERの下では既定値に等しいtrailerFieldはそもそも現れてはなりません

これでCMS署名には、独立して失敗しうる2つの半分ができます。1つは暗号操作そのもの。もう1つは、操作がどう行われたかを検証器に告げるASN.1です。前者を正しく後者を誤ると、規格に従う検証器には、あなたの文書を偽造と区別する手段がありません。この記事が扱うのはASN.1の半分です

バイト列が正しいRSASSA-PSS署名を検証器が拒否する理由

RSASSA-PSSが、検証器がアルゴリズムOIDからパラメーターを割り出せない唯一のRSA方式だからです。PKCS#1 v1.5はsha256WithRSAEncryptionで完全に固定され、そのパラメーターは裸のNULLです。PSSはメッセージハッシュと、それ自身のハッシュを持つマスク生成関数と、ソルト長でパラメーター化され、RFC 8017 §A.2.3は3つすべてを署名者に委ねます。AlgorithmIdentifierが署名者の選択を運び、検証器はEMSA-PSS-VERIFYが走る前にそれらを正確に再現しなければなりません

検証器がパースできないブロックは、モジュラー指数演算が始まる前に検証を終わらせます。違うようにパースできるブロックのほうが厄介です。RFC 4055 §3.1はsaltLengthに既定値20を与えているので、ソルトフィールドを見つけられなかったデコーダーは、32で計算された署名に対して、20バイトのソルトでEMSA-PSS-VERIFYを走らせます。結果は不正な署名として報告され、メタデータを指すものは何もありません。検証器の厳格さが、2つの失敗のどちらを見るかを決め、どちらのエラーメッセージもAlgorithmIdentifierには言及しません

RSASSA-PSS-paramsの4フィールドとそのタグ

RFC 4055 §3.1はRSASSA-PSS-paramsを、明示的なコンテキスト固有タグとDEFAULT値を持つ4フィールドのSEQUENCEとして定義します。DERでは、明示的タグ付けは各フィールドを構造型のコンテキストTLV、A0からA3で包み、フィールド自身のユニバーサル符号化がその内側に入ります

フィールドタグ(DERバイト)DEFAULTETSI TS 119 312 §7の値符号化されるか
hashAlgorithm[0] (A0)sha1SHA-256はい
maskGenAlgorithm[1] (A1)mgf1SHA1MGF1 with SHA-256(SHA-256によるMGF1)はい
saltLength[2] (A2)2032(ダイジェスト長)はい
trailerField[3] (A3)trailerFieldBC (1)1いいえ、既定値と等しいため

タグがあるのは、どのフィールドも既定値を通じて省略可能だからです。hashAlgorithmとmaskGenAlgorithmはどちらもAlgorithmIdentifierのSEQUENCEです。タグがなければ、SEQUENCEを1つだけ保持するパラメーターブロックを見たデコーダーは、どちらなのか判別できません。タグがあれば、タグ番号がフィールドを名指し、エンコーダーがどの隣人を省いていても変わりません

PDFium ComponentのRFC 4055に従ったRSASSA-PSS-paramsの図:hashAlgorithm A0、maskGenAlgorithm A1、saltLength A2がDEFAULT値を持つ明示的なコンテキストタグを運び、ETSIプロファイルはSHA-256、SHA-256を使うMGF1、ソルト32を出力し、trailerField A3はtrailerFieldBCに等しいためDERはそれを完全に省略します
すべてのフィールドがタグ付けされているのは、すべてのフィールドが既定値によって任意だからです。そのためエンコーダーがどの隣接フィールドを省いても、タグ番号がフィールドを特定します

ソルトはダイジェスト長に等しいものを選び、そこに固定します。「maximum salt」や「salt length auto」を提供するAPIもありますが、これはキーsizeに依存する値を生みます。RFC 8017の下では合法ですが、AlgorithmIdentifierはキーごとに変わる数を述べることになります。しかもSHA-256には32バイトのソルトを、とETSI TS 119 312 §7はそもそも求めています。PDFium Componentの内蔵署名バックエンドはすべて32に固定し、同じ数をプラットフォーム呼び出しへ渡します。WindowsでNCryptSignHashに渡すBCRYPT_PSS_PADDING_INFOのcbSalt、PKCS#11トークン向けのCK_RSA_PKCS_PSS_PARAMSのsLen、OpenSSLの下ではEVP_PKEY_CTX_set_rsa_pss_saltlen、そしてmacOSでkSecKeyAlgorithmRSASignatureDigestPSSSHA256が含意するダイジェスト長のソルトです

自分のICmsSignerのためにGetSignatureAlgorithmParamsを書く

PDFium Componentでは、すべての署名バックエンドがFPdfCms.pasのICmsSignerインターフェースを実装し、パラメーターブロックは1つのメソッドGetSignatureAlgorithmParamsから来ます。CMSビルダーはその結果をGetSignatureAlgorithmOidのOIDと、TDerWriter.AlgIdWithParamsを通じてペアにします。このメソッドと出会うのは、ConfigurePadesSignerProviderで自分の署名者を差し込むときです。ネットワークHSM、署名サービス、内蔵バックエンドがカバーしないスマートカードドライバです。ほとんどの人が最初に書く符号化は、明らかに包みが必要な2つのAlgorithmIdentifierにはタグを付け、2つの整数は裸のままにします

// よくある最初の試み。[0] と [1] にはタグ、[2] と [3] にはなし
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID('1.2.840.113549.1.1.8'),
    W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
  W.IntegerOf(32),      // [2] EXPLICIT が必要な場所の裸の INTEGER
  W.IntegerOf(1)));     // DEFAULT に等しい trailerField。存在してはなりません

デコーダーの道すがらに歩いてみます。デコーダーはA0を見てハッシュを読み、A1を見てマスク生成関数を読み、それから02 01 20、ユニバーサルINTEGERに出会います。RSASSA-PSS-paramsには、タグなしのINTEGERメンバーはどこにもありません。だから厳格なデコーダーは止まります。寛容なデコーダーは未知の要素を飛ばし、A2を見つけられないまま既定のソルト20を割り当て、それから2つ目のはぐれINTEGER、02 01 01につまずきます。どちらの経路も32には届きません

RSASSA-PSS-paramsのタグ付けの罠の図。A0とA1の後、誤タグのブロックはA2の明示タグがあるべき場所で裸の02 01 20に出会い、厳格なデコーダーは停止し、寛容なものは既定の20バイトソルトへ後退します。正しい符号化は32バイトのソルトをA2で包みます
RSASSA-PSS-paramsにタグなしのINTEGERメンバーはないので、そこのはぐれたバイトはパース失敗か、既定のソルト長への静かなフォールバックのどちらかになり、どちらの経路も署名者の32には届きません

適合する版は、FPdfAsn1.pasのTDerWriter.ContextSpecificを、既定値を持たないフィールドごとに1回、合計3回呼びます。それぞれConstructedをTrueにして、結果が明示的なラッパーになるようにします。trailerフィールドのための行はありません。これはWindows CNG、macOS Keychain、PKCS#11、OpenSSLの各バックエンドが出すのと同じブロックで、ここではカスタム署名者が運ぶ形で書いています

uses
  System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;

type
  TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
  private
    FUsePss: Boolean;
  public
    function GetDigestAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmParams: TBytes;
    function GetSignerId: TCmsSignerId;
    function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
  end;

// 自作のクラス。他の4つのメソッドは HSM との対話で、ここでは省略します
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if not FUsePss then
    Exit(nil);   // v1.5 と ECDSA。AlgIdWithParams は nil に NULL を書きます
  W := TDerWriter.Create;
  try
    // RFC 4055 3.1: [0] ハッシュ、[1] ハッシュ付き MGF1、[2] ソルト。
    // [3] trailerField = 1 は DEFAULT に等しいので、X.690 11.5 が省略します
    Result := W.Sequence(Concat3(
      W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
      W.ContextSpecific(1, W.Sequence(ConcatBytes(
        W.OID('1.2.840.113549.1.1.8'),             // id-mgf1
        W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
      W.ContextSpecific(2, W.IntegerOf(32), True)));
  finally
    W.Free;
  end;
end;

TDerWriter.AlgIdはNULLパラメータ付きのAlgorithmIdentifierを書きます。RFC 4055 §2.1がネストしたSHA-256識別子についてエンコーダーに生成せよと命じているものです。できあがるバイトは目視確認できるほど短いです。外側の30 34、15バイトのSHA-256 AlgorithmIdentifierを囲むA0 0F、28バイトのMGF1 AlgorithmIdentifierを囲むA1 1C、そしてソルトのA2 03 02 01 20。GetSignatureAlgorithmParamsで書くソルトは、HSMが実際に使うものと一致しなければなりません。サービスが「PSS with SHA-256」しか露出せず、ソルト長を教えてくれないなら、出荷前に確かめてください。AlgorithmIdentifierはそれについての主張なのですから

trailerFieldが[3]としてタグ付けされず省略される理由

X.690 §11.5は、DERエンコーダーが値がDEFAULTと等しいコンポーネントを符号化してはならないと定めます。trailerFieldはDEFAULTtrailerFieldBC、整数の1を持ち、RFC 8017が定義するPSSはそのtrailerしか使いません。だから最初の試みへの自明な修正、W.IntegerOf(1)をW.ContextSpecific(3, W.IntegerOf(1), True)に置き換えることは、A3 03 02 01 01を作ります。寛容なBERデコーダーは受け入れ、厳格なDERデコーダーは拒否して構わないものです。値は正しい。存在が違うのです

同じ規則が、他の3つのフィールドが現れる理由も説明します。SHA-256はsha1ではなく、SHA-256によるMGF1はmgf1SHA1ではなく、32は20ではないからです。20バイトソルトでSHA-1を使う署名者は、ブロック全体を空のSEQUENCE、30 00へ崩します。この形は、「パラメーターなし」は常に05 00と書かれると思い込む人をつまずかせます。CMSのid-RSASSA-PSSではパラメーターフィールドが必須で、空のSEQUENCEは「すべて既定値」を意味し、NULLはRSASSA-PSS-paramsとして有効な値ではありません

PDFium ComponentのPSSパラメーターにおけるX.690 11.5のルールの図。DEFAULTのtrailerFieldBCに等しいtrailerFieldは省略されたままでなければならず、タグ付きのA3ラッパーは厳格なDERが拒否する符号化です。一方、saltLength 32は既定の20と異なるためA2として存在しなければなりません
DERは1つの値がちょうど1つの符号化を持つために存在します。既定値に等しいコンポーネントはすでに最短の符号化、つまりそもそも現れないという形を持っています

BERはエンコーダーに既定値のコンポーネントを含めることを許します。DERは禁じます。DERは1つの値がちょうど1つの符号化を持つために存在し、合法的な符号化を2つ持つ構造への署名は、言い争いの種になるからです。パラメーターブロックはここではDERから逃げられません。SignerInfoの外側のsignatureAlgorithmに座り、RFC 6211のcmsAlgorithmProtection属性を通じて署名済み属性へ複製され、そこではすべてのバイトが署名で覆われます

自分が作らなかったCMSを検査する

cmsAlgorithmProtectionの内側のコピーは、誤タグのブロックを捕まえられません。その属性と外側のsignatureAlgorithmは同じ呼び出しから来るので、常に一致し、構造と自分自身の比較は通ります。問題を捕まえるのは、パラメーターフィールドをフィールドごとに読むデコーダーです。PDFium ComponentのFPdfPades.pasのInspectPadesSignatureAlgorithmがそれをします。A0からA3だけを受け付け、それぞれ多くとも1回、昇順で現れることを要求し、欠けているものにはRFC 4055の既定値を当てはめ、実効値をTPadesRsaPssParametersで報告します。ブロックの中の裸のINTEGERはSignaturePss.ValidをFalseのままにし、EvaluatePadesSignatureAlgorithmはその後PolicyStatusをpcsInvalidにします。SignerInfoのダイジェストアルゴリズムと違うPSSハッシュも拒否します。寛容なsha1への後退が生むもの、まさにそれです

最も重要になるのはリモート署名です。外部サービスが完成したCMSを返し、CompletePadesRemoteSignatureがそれを準備済みPDFへ埋め込みます。埋める前にブロブを検査してください。その時点での拒否はAPI呼び出し1回で済みます。客のバリデータから知ることになると、サービスの運営者との再署名ラウンドになります

uses
  System.SysUtils, FPdfPades;

// CompletePadesRemoteSignature の前に自分で足す検査の提案です
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
  OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
  Info: TPadesSignatureAlgorithmInfo;
  Values: string;
begin
  Result := False;
  Why := '';
  // nil: インスペクターに CMS 内の署名者証明書を探させます
  if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
  begin
    Why := 'CMS SignerInfo could not be parsed';
    Exit;
  end;
  if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
  begin
    if not Info.SignaturePss.Valid then
    begin
      Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
      Exit;
    end;
    // 実効値。既定値も含みます。欠けた A2 はここでは 20 として現れます
    Why := Format('hash %s, MGF hash %s, salt %d', [
      string(Info.SignaturePss.HashAlgorithmOid),
      string(Info.SignaturePss.MaskGenHashAlgorithmOid),
      Info.SignaturePss.SaltLength]);
  end;
  if Info.PolicyStatus = pcsInvalid then
    Exit;   // Why にはログ用に復号済みの値が残っています
  Values := Why;
  Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
  if Result then
    Why := Values;   // 保護チェックは成功時に Why をクリアします
end;

Why文字列は成功時にもログに残してください。SHA-256ハッシュの隣のソルト長20は、A2が消えたパラメーターブロックの指紋です。サービス自身のログが何と言おうと。コードなしで手早く見るには、openssl asn1parse -inform DER -in signature.p7sが木を印字します。id-RSASSA-PSSのOIDの下で見たいのは、16進の20というINTEGERを包むcont [ 2 ]で、cont [ 3 ]はまったくないことです。そのSEQUENCEの中のトップレベルINTEGERは、最初のコードブロックの誤タグ符号化です

PSSの話の残りとの位置関係

パラメーター符号化は、PAdES署名でPSSが狂う3つの別々の道の1つで、切り分けておくとデバッグが短くなります。macOSでは特定のキーや古いシステムがPSSを提供しないことがあり、そのときバックエンドはPKCS#1 v1.5で署名し、一致させるためにsha256WithRSAEncryptionを宣言します。その能力の問題はmacOS Keychainの識別情報でPAdES署名する方法で扱います。PKCS#11トークンは、CK_RSA_PKCS_PSS_PARAMSを、呼び出し側が書いたのと違う整数幅で読むことがあり、これはABIの問いで、CK_ULONGとPKCS#11のパッキングの罠で扱います。3つ目は上の話です。鍵は意思があり、トークンは正しいバイトを計算し、それを記述するDERがRFC 4055 §3.1に従わない

署名済み属性のSETには、自己整合性の盲点の自分版があります。1つの順序でハッシュされ別の順序で出力されたSETは、外部の検証器がハッシュを計算し直すまで正常に見えます。CMS signedAttrsとDER SET OFのソートを参照してください。上の検査が差し込まれる、準備し、リモートで署名し、完成させるサイクルについては、HSMによるリモートPAdES署名が2フェーズのプロトコルと、完成ステップが拒否するものを扱います

PDFium Delphi componentは署名バックエンドをソース同梱で出荷するので、Windows、macOS、PKCS#11、OpenSSLの各ユニットのGetSignatureAlgorithmParamsの本体は、読み、あなたの署名者の出力の隣にバイト単位でダンプするためにそこにあります