CMSコンテナ内のECDSAのsignatureValueはDERのSEQUENCE { INTEGER r, INTEGER s }である。Windows CNGの関数BCryptVerifySignatureはそのどちらも受け付けない。求めているのはタグも長さもない、IEEE P1363形式の固定幅r || sである。DelphiとC++Builder向けのネイティブVCL PDFコンポーネントであるHotPDFは、鍵をインポートする前に厳格なDERルールのもとでこの両者を変換する
これが防いでいる失敗は、特定的で意気消沈させられる類のものだ。Acrobatは文書を開いて緑のチェックを表示する。同じバイト列を辿るあなた自身のベリファイアは無効を返す。あるいはCNGはそれ以上の説明もなくSTATUS_INVALID_SIGNATUREを返す。署名には何も問題がない。問題なのは、約70バイトのASN.1が64バイトの生の整数を期待するAPIに渡されたことであり、この不一致は探し方を知らなければ見えないままなのだ
なぜBCryptVerifySignatureは有効なECDSA署名を拒否するのか
この呼び出しの両側が異なる署名エンコーディングを話しており、どちらもそれを表明しないからだ。ISO 32000-1 §12.8は署名辞書が/ContentsにCMSのブロブを運ぶと述べており、RFC 5652 §5.3は各SignerInfo内のsignatureValueが、署名アルゴリズムが定めるものを内容とするOCTET STRINGであると述べている。ECDSAの場合、その内容はSEC 1のDER構造である:2つのINTEGERを保持するSEQUENCEだ。これは設計上可変長である。rとsは整数であり、DERは整数から先頭のゼロオクテットを取り除くからだ
IEEE P1363は逆の見方を取る。署名を2つの座標を連結したものとして定義し、それぞれを曲線のフィールド幅ちょうどのバイト数までゼロで左パディングする。P-256の署名は常に64バイトである。同じ署名のDERエンコーディングは通常70か71バイトで、約8バイトから72バイトまでの範囲になりうる。DER形式のものをそのままBCryptVerifySignatureに渡せば、長さチェックだけでその呼び出しは詰んでしまう。だからこそHotPDFは検証の後ではなく前に正規化を行う
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
署名パーサーが緩めてはならないDERルール
ここに挙げる拒否のすべては、HotPDFが意図的に行っている拒否であり、それぞれが寛容なパーサーなら開けたままにしてしまう経路を1つずつ塞いでいる。コンバータを書くときの誘惑は、2つのINTEGERノードを見つけてその内容をコピーし、先に進むことである。それは整った入力に対してはうまく動くが、敵対的な入力に対しては可鍛性のある(malleable)再エンコーディングの一群を静かに受け入れてしまう。そこでHPDFECDSANormalizeSignatureは負の整数——つまりrやsの最初の内容オクテットの最上位ビットが立っているもの——を拒否する。有効なECDSAスカラーは正であるはずだからだ。すべてゼロの値も拒否する。r = 0やs = 0は決して正当な署名ではないからだ。冗長な先頭ゼロオクテットも拒否する。X.690 §8.3はちょうど1つだけを、しかも次のオクテットがそれなしでは負として読めてしまう場合にのみ許可しており、0x80未満のオクテットが続く00は署名ではなく再エンコーディングだからだ。最小でない長さヘッダーも拒否する。X.690 §10.1は最も少ないオクテット数でエンコードされた確定形式を要求しており、短形式で済むはずの長形式の長さは同じ意味を運ぶ別のバイト列だからだ。曲線の座標サイズより幅の広い整数も拒否する。その値はフィールド要素になりえないからだ。そしてsの後に続く末尾ノードも、全長がブロブ全体の長さと一致しない外側のSEQUENCEも拒否する
最後の2つは見た目以上に重要である。SEQUENCEの後の末尾バイトは典型的な署名可鍛性のトリックだ:ゴミを付け加えても、寛容なベリファイアは依然として有効だと言いながら、それが検証したバイト列は実際に署名されたバイト列ではない。同じ本能がPKCS#12解析に関するノートで説明したASN.1長さの堅牢化を駆動しており、ここでも同じ本能が働いている。検証経路においては、準拠した署名者が一度も出力していない構造を受け入れてしまうことは、親切ではなく欠陥である
座標幅は署名ではなく曲線に属する
HotPDFは出力幅を、たった今パースしたDERの長さからではなく、指定された曲線のOIDから導く。これが変換の後半であり、微妙に間違えやすい半分である。RFC 5480 §2.1.1は証明書のSubjectPublicKeyInfoパラメータの中で曲線を識別しており、HPDFECDSACurveFromOIDはHotPDFがサポートする3つのOIDをマッピングする:P-256には1.2.840.10045.3.1.7、P-384には1.3.132.0.34、P-521には1.3.132.0.35である。続いてHPDFECDSACoordinateSizeは32、48、あるいは66バイトを返し、P1363のバッファはその2倍、すなわち64、96、132バイトになる。デコードされた各整数はその半分の中で右詰めされるため、短いrはシフトされるのではなく左側にゼロパディングされる。P-521は人をつまずかせる存在だ。521ビットは65.125バイトであり、切り上げて66バイトになるため、2のべき乗の直感では予測できない132バイトの署名になる。公開鍵はRFC 5480 §2.2に従い非圧縮のEC点として同行する。これは0x04に続いてXとYが並ぶ形であり、HotPDFはCNGに触れる前に、それがちょうど1 + 2 * CoordinateSizeバイトであり0x04で始まっていることを確認する
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
最後のパラメータに注目してほしい。HPDFECDSAVerifyDigestは、ハードウェアトークンやリモート署名サービスから生のr || sを受け取り、すでに固定幅の署名を保持している呼び出し側のためにeseP1363も受け付ける。この経路でも長さのチェックと、両半分それぞれに対するゼロでないことのチェックは変わらず強制される。したがって正しいサイズでゼロだけで埋まったバッファは、プロバイダにそのまま渡されることなく拒否される
なぜ汎用のECDSAアルゴリズム名は古いWindowsで失敗するのか
汎用の名前は、出荷しようとしているデプロイ先のベースよりも新しいからだ。CNGはインポートされた鍵から曲線を推測するアルゴリズム識別子ECDSAを公開しており、このコードを書くうえでは綺麗なやり方だが、BCryptOpenAlgorithmProviderがそれを解決できると保証されているのはより新しいWindowsバージョンだけである。古いマシンではオープン呼び出しが失敗し、プロバイダハンドルはnilのままとなり、アプリケーション内のあらゆるECDSA検証は、まったく問題のない署名に対してサポート外だと報告する。HotPDFは代わりに曲線ごとの識別子を開くことでこの崖を回避している。ECDSA_P256、ECDSA_P384、ECDSA_P521を一度だけ解決し、曲線ごとに1つのプロバイダハンドルをキャッシュし、ユニットのファイナライズでそれらを閉じる。その後、それぞれの検証は安価な作業だけを行う:ECCPUBLICBLOBから一時的な公開鍵をインポートし、BCryptVerifySignatureを呼び、その鍵を破棄する。繰り返しのLoadLibraryも、繰り返しのGetProcAddressも、署名ごとのプロバイダのオープンとクローズもない。数百件の文書を一括検証すればその違いが体感でき、負荷の下でプロバイダハンドルをいたずらに増減させることになるサービスプロセスでも同じことが言える
結果コードはこの区別について誠実であり続ける。evrProviderUnavailableはマシンがHotPDFにプロバイダを提供できなかったことを意味し、evrInvalidはCNGがSTATUS_INVALID_SIGNATUREと答えたことを意味する。この2つを1つの失敗に潰してしまうことが、デプロイの問題が偽造文書として誤報告される経緯である。環境の失敗と暗号的な失敗を分けるこの同じ区別は、証明書ストア署名とバイト順序に関する記事で扱う署名側のCNGとCAPIの処理にも一貫して流れている
どの証明書がこれに署名したのか:SignerIdentifierは2つの異なるもの
RFC 5652 §5.3はSignerIdentifierをCHOICEにしており、片方の腕しか扱わないベリファイアは間違った鍵に対して静かに検証を行ってしまう。1つ目の腕はissuerAndSerialNumberであり、生のDERのissuer NameとシリアルINTEGERを保持するSEQUENCEで、これを照合するのはCMSのcertificates集合内の各証明書とのバイト比較である。2つ目の腕は[0] subjectKeyIdentifierであり、暗黙的にタグ付けされたOCTET STRINGで、これを照合するにはヘッダーフィールドを比較するだけでなく証明書を掘り下げる必要がある
この掘り下げには人を驚かせる層がある。鍵識別子はX.509v3拡張の中にあるため、HotPDFはtbsCertificateの[3]拡張フィールドを走査し、OIDが2.5.29.14である拡張を見つけ、任意項目のcritical BOOLEANをスキップし、extnValue OCTET STRINGを取り出す。そのオクテット文字列自体が識別子ではない。RFC 5280 §4.2.1.2によればその内容自体もDERであり、KeyIdentifier型は別のOCTET STRINGであるため、実際のバイト列に到達するには二度目のパースが必要になる。この1層手前で止まってしまうと、22バイトのラッパーを20バイトの識別子と比較することになり、どの証明書も一致せず、ベリファイアは次に書いたどんなヒューリスティックにも頼ることになる——これこそが本当の危険である。集合内の最初の証明書を取るというのは誘惑的な近道であり、CMSがチェーンを運んでいる場合には常に間違っている。それはほとんどの場合に当てはまる。リーフ証明書が最初に来る保証はないからだ。HotPDFは、コンテナがちょうど1つの証明書しか保持していない場合に限り、一致しない証明書を受け入れる。複数の証明書が存在する場合は、正確なSignerIdentifierの一致が必須である。中間CAの公開鍵に対してダイジェストを検証しても親切なエラーにはならず、何も問題のない文書に対して自信満々の無効という結果が生まれるだけだ
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveNameはP-256、P-384、P-521のいずれかを報告するため、監査ログには単に「ECDSA」という語だけでなく、実際に使われた曲線が記録される。この呼び出しを取り巻く文書レベルの仕組み、とりわけ/ByteRangeのセグメントがどうハッシュされ、なぜダイジェストがパース済みのオブジェクトツリーではなくファイルそのものに対して計算されなければならないかについては、PDF署名検証に関する関連記事の主題である
これが与えてくれないもの
HPDFECDSAVerifyDigestからの緑の結果が答える問いはただ1つだ:このバイト列は、この公開鍵に対応する秘密鍵によって署名された。それはその鍵が信頼すべき誰かのものであるかどうかについては何も語らない。信頼アンカーへのチェーン構築、CRLやOCSPによる失効確認、ポリシーチェックは別の作業であり、それらなしに有効な署名だと報告する製品は、ユーザーが想定するよりも少ないことしか報告していない。証明書の有効期限は、まさにその理由からTHPDFSignatureInfoで別途表面化される:署名は暗号的には検証されても、それを作った証明書は2年前に失効していたということがありうるからだ。曲線サポートも意図的に狭くしてある。扱われるのは3つのNIST素数曲線だけであり、それ以外の曲線に対する署名は推測ではなくサポート外を返す。CNG経路はWindows専用であり、VCLコンポーネントとしては正しいトレードオフだが、これを中心にクロスプラットフォームのサービスを計画する前には述べておく価値がある。そして厳格さは設定可能ではない。何らかの古いレガシー署名者が最小でないDER長を出力していたからといってそれを受け入れる寛容モードは存在しない。もし本番環境でそのようなファイルに出会ったなら、誠実な対応はそれを記録して制作元を追及することであり、そのファイルが通るまでパーサーを緩めることではない
ここで説明したECDSA検証経路は、RSA PKCS#1 v1.5とRSA-PSSの経路、そして完全な署名情報レコードとともに、DelphiとC++Builder向けの標準HotPDF Componentの一部として提供される。製品ページにはデジタル署名の完全なリファレンスが掲載されている