ML-DSA-65署名を運ぶPDFは、生成元のツールチェーンの検査を通過しながら、そのアルゴリズムを定義する拡張がどれかをファイルが宣言することを主張する検証器からは、サポートされていないアルゴリズムとして返ってくることがあります。公開鍵は1,952バイト、そのパラメータセットのFIPS 204サイズちょうどです。署名バイトは無傷で、バイト範囲はファイル全体を覆っています。そのような検証器が探しているのは、文書Catalog内の1つのエントリ、その拡張を名指すものです。ISO 32000-2はML-DSAをまったく列挙していないからです
署名側には別の壁があります。ハードウェアトークン、クラウドHSM、国のeIDカードは要求に応じて署名を計算してくれますが、秘密鍵をアプリケーションに手渡すことはどれもしないので、PFXファイルをロードしてプロセス内で署名することを想定した署名コードには、ロードするものが何もありません。両方の問題は判断がどこに住むかの話です。アルゴリズム宣言はファイルの中に住み、鍵はDelphiコードが届かないどこかに住んでいます
妥当なML-DSA署名がなぜサポート外として返ってくるのか
HotPDFがML-DSA-44、ML-DSA-65、ML-DSA-87のCMS署名を検証するのは、読み込んだ文書がCatalogでHotPDF開発者拡張を宣言しているときだけです。その宣言がなければ、格子署名にはどんなに健全な暗号でもsvUnsupportedAlgorithmが返ります。理由は再現性です。基底規格が名指さないアルゴリズムを使い、どの拡張に依存しているかも言わないファイルは、別の実装が繰り返す根拠を持たない判定を生みます
生成側では、THotPDF.EnsureMLDSAExtensionsが/Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >>をCatalogへ書き込み、旧バージョン向けだった文書をPDF 2.0へ引き上げます。読み取り側では、LoadedDocumentDeclaresMLDSAExtensionはその辞書が存在し、/BaseVersionが2.0に等しく/ExtensionLevelが1以上のときに限りTrueを返します。VerifyLoadedSignatureWithOptionsは両者を組み合わせ、呼び出し側のAllowMLDSAフラグを宣言テストとANDしてから検証を始めるので、未宣言のファイルでフラグを立てても何も変わりません
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'contract-pq.pdf';
Pdf.StrictVersionLock := False; // デフォルト。呼び出しがファイルをPDF 2.0へ引き上げられる
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
Pdf.EnsureMLDSAExtensions; // 署名がCatalogを覆う前に宣言する
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
このスニペットには重みのある細部が2つあります。呼び出しは文書の保存と署名の前に行う必要があります。Catalogは署名バイト範囲の中にあり、後からパッチを当てるのは、署名済みファイルへの未署名の変更か、どのバリデータも変更として列挙する新しいインクリメンタルリビジョンか、どちらかになるからです。2つ目の細部はStrictVersionLockです。ロックがオンで文書がPDF 2.0より下に固定されている場合、EnsureMLDSAExtensionsは何も書かずに戻ります。PDF 2.0の拡張は1.7のファイルでは宣言できないからです。アーカイブの理由でバージョンを固定しているなら、宣言を当てにする前にVersion = pdf20を確認してください
EdDSAとML-DSAに1つの検証呼び出し
VerifyLoadedSignatureWithOptionsは署名インデックス、ソースストリーム、THPDFCMSVerifyOptionsレコード、outのTHPDFSignatureInfoを受け取ります。レコードはTHPDFCMSVerifyOptions.Defaultから始めます。AllowMLDSAはオフ、CMSコンテナは署名者256人まで、副署名のネストは8までという初期値です。新しいアルゴリズムに関係するフィールドが3つあります。SignatureProviderは自前の検証器にまず答えさせます。OpenSSLLibraryPathは純粋モードのEd25519とEd448検証を行うOpenSSL 3のlibcryptoを指名します。AllowMLDSAは格子アルゴリズムへの選択で、前述のCatalogテストに従います。レコードは署名ポリシーのピン留めフィールド(RequireSignaturePolicy、ExpectedSignaturePolicyOID、ExpectedSignaturePolicyHash)も運びます。こちらはすべてのアルゴリズムファミリに適用されます
組み込みの経路はアルゴリズムで分かれます。Ed25519とEd448はOpenSSL 3を通ります。パスが与えられないとき、プラットフォームプロバイダはWin64の通常のDLL検索順序でlibcrypto-3-x64.dll、libcrypto-3.dll、libcrypto.dllを試します。ML-DSAはWindows CNGのML-DSAアルゴリズムプロバイダを通り、それを提供しないWindowsビルドでは結果はサポート外です。OpenSSLLibraryPathは実行ファイルの隣に出荷するコピーに設定してください。空のままだと、判定は別のインストーラが検索パスに落としたlibcrypto次第になり、監査で説明するのが大変なことになります
自前のSignatureProviderは最初に問われます。spsUnsupportedを返せば、組み込みのプラットフォームプロバイダが同じ要求を受け取ります。ML-DSAだけを知るプロバイダが、デフォルトのEdDSA処理を再実装せずにその前に座れるということです
var
Pdf: THotPDF;
PQ: THPDFCallbackSignatureProvider;
Opts: THPDFCMSVerifyOptions;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
Src: TFileStream;
begin
PQ := THPDFCallbackSignatureProvider.Create(
function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
begin
if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
Exit(spsUnsupported); // EdDSAはプラットフォームプロバイダへ回す
// MyFips204VerifyはML-DSAライブラリの自前ラッパー。
// Req.InputはDERエンコードされた署名属性(純粋モード)を保持する
Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
Req.Input, Req.Signature);
end);
Pdf := THotPDF.Create(nil);
Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
try
Pdf.LoadFromFile('contract-pq.pdf');
Opts := THPDFCMSVerifyOptions.Default;
Opts.SignatureProvider := PQ;
Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
Opts.AllowMLDSA := True; // それでもCatalog宣言とANDされる
Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
if Status = svValid then
Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
string(Info.SignatureAlgorithmOID));
finally
Src.Free;
Pdf.Free;
PQ.Free; // オプションレコードは所有しない
end;
end;
設定が期待したアルゴリズムではなく、Info.SignatureAlgorithmOIDをログしてください。ML-DSA-65なら2.16.840.1.101.3.4.3.18で、ファイルに見つかったOIDを記録する監査証跡は、設定値では答えられない質問に後で答えます。カスタムプロバイダがまったく要らないなら、短いVerifyLoadedSignature(SigIndex, Source, Info)オーバーロードがデフォルトオプションを組み立て、AllowMLDSAをCatalog宣言から設定してくれます
外部生成のML-DSAとEdDSA CMSがひっかかるエンコード規則
両アルゴリズムファミリとも、完全なDERエンコードされた署名属性の上の純粋モードで検証され、署名属性を持たないSignerInfoはサポート外として報告されます。EdDSAではAlgorithmIdentifierのパラメータが存在してはなりません。手作りCMSのよくある間違いは、署名属性をまずSHA-512やSHAKE256でハッシュしそのダイジェストに署名することで、CMSのどこにも宣言されていないprehash変種へ静かにPureEdDSAを変えてしまいます。ML-DSAでは、検証器はパラメータセットとメッセージダイジェストの組み合わせも検査します。ML-DSA-44はSHA-256、SHA-384、SHA-512、SHAKE256を受け入れ、ML-DSA-65はSHA-384以上、ML-DSA-87はSHA-512かSHAKE256を必要とします。組み合わせの不一致はInfo.DigestConsistencyIssueに現れます。別のツールチェーンのファイルが失敗したときに最初に読むべきフィールドです
EdDSAには独自の登録の話があります。ISO/TS 32002がEd25519とEd448をPDF 2.0署名に登録する拡張で、EnsureISO32002Extensionsは対応するISO_エントリをExtensionLevel 32002付きでCatalogへ足します。検証はそのエントリなしでもEdDSAを受け入れますが、自分が生成するファイルはそれを運ぶべきです。ML-DSA宣言と同じ再現性の理由です
署名プロバイダが頼まれること
署名プロバイダは1つのTHPDFSignatureProviderRequestを受け取り、THPDFSignatureProviderStatusと、成功なら署名バイトを返します。要求はアルゴリズムとそのOID、ダイジェストアルゴリズムOID、PSSソルト長、Inputがメッセージか(sikMessage)事前計算ダイジェストか(sikDigest)、入力バイト、署名者証明書、鍵識別子、その呼び出しに固有の操作識別子を運びます。トークンドライバや署名サービスがすでに話す語彙で、どれも鍵がプロセスに入ることを要求しません
各プロバイダはspcSign、spcCancel、spcHardwareKey、spcRemoteKeyのような能力セットも広告します。ライブラリにはいくつかの実装が同梱されます:
THPDFCallbackSignatureProviderは検証、署名、キャンセルのための無名メソッドを包みます。既存の社内署名ルーチンからPDF署名までの最短経路ですTHPDFRemoteSignatureProviderは再試行上限、キャンセルレジストリ、入力と署名サイズの上限つきでトランスポート関数を包みます。振る舞わないリモートサービスがCMSエンコーダへ過大な応答を押し込めませんTHPDFPKCS11SignatureProviderは、あなたのコードがすでに開いてログインしたセッションと秘密鍵ハンドルを借ります。HotPDFはPINを一切見ず、トークンセッションを管理しません。C_Sign呼び出しを直列化し、RSA PKCS#1 v1.5のためにDigestInfoを前置きし、メカニズムパラメータを通じて実際のソルト長をRSA-PSSへ渡し、生のECDSAr||s結果をCMSが期待するDER形式へ変換しますTHPDFWindowsKeyStorageSignatureProviderはWindows CNG鍵ストレージを通じて署名し、スマートカードとTPMベースの鍵をカバーし、ハードウェアベースの鍵を要求できます
PKCS#11プロバイダはダイジェスト入力を受け取るので、RSA PKCS#1 v1.5、RSA-PSS、ECDSAを扱います。Ed25519とEd448は完全な署名属性メッセージに署名するので、コールバックまたはリモートプロバイダを通ります。そこではあなたのコードがReq.Inputをメッセージとしてトークンやサービスへ渡します
var
Provider: THPDFRemoteSignatureProvider;
Opts: THPDFCMSSignOptions;
CertDER: TBytes;
InStream, OutStream: TFileStream;
begin
CertDER := TFile.ReadAllBytes('signer.cer'); // System.IOUtils。リモート鍵のDER証明書
Provider := THPDFRemoteSignatureProvider.Create(
function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
out Signature: TBytes): THPDFSignatureProviderStatus
begin
// PostToSigningServiceは自前のHTTPクライアント。タイムアウトと5xxは
// spsProviderErrorへ、承認の拒否はspsCancelledへ、未知の鍵はspsUnsupportedへ対応させる
Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
Req.Input, Signature);
end,
3, // RetryLimit。合計で最大4回の試行
1048576, // MaxInputBytes
65536); // MaxSignatureBytes
Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
Opts.SignatureScheme := cmsRSAPSS;
Opts.DigestAlgorithm := cmsdaSHA256;
Opts.PSSSaltLength := 32;
// contract-unsigned.pdfはAddSignedSignatureFieldのプレースホルダ付きで保存済み
InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
try
HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
Provider, 'contracts-2026', Opts);
finally
OutStream.Free;
InStream.Free;
Provider.Free;
end;
end;
6つのステータスと、それぞれが引き起こすべき反応
THPDFSignatureProviderStatusに6つの値があるのは、正しい反応がそれぞれ違うからです。spsInvalidは暗号の失敗で、セキュリティログに入ります。spsUnsupportedはプロバイダがそのアルゴリズムを実装していないことで、デプロイの問題です。spsMalformedは要求か応答の形が間違っていたことです。spsProviderErrorは輸送か機器の障害で、再試行で成功するかもしれません。spsCancelledは人がトークンのプロンプトを拒否したことで、再試行はただ迷惑なだけです
リモートプロバイダはこの方針を組み込みます。spsProviderErrorでのみ再試行し、各試行の前にキャンセルレジストリを検査し、空または過大な入力をトランスポートの呼び出し前にspsMalformedとして拒否し、空または過大な署名つきの「有効」な答えをspsMalformedへ格下げします。検証側では、プロバイダの答えは検証ステータスへ写像されます。spsInvalidはsvSignatureInvalidに、spsMalformedはsvMalformedに、spsProviderErrorとspsCancelledはどちらもsvCryptoProviderErrorに、そしてプラットフォームプロバイダも要求を拒否した後ならspsUnsupportedはsvUnsupportedAlgorithmになります
プロバイダを書くなら、あらゆる予想外の状況をspsInvalidへ写そうとする誘惑に抗ってください。それはDLLの欠落や期限切れのサービストークンを「顧客の署名は偽造された」という報告に変え、誰かが顧客にそれを説明する羽目になります。spsInvalidは数学が実際に失敗したときだけ返してください。署名では、空でない署名とともにだけspsValidを返します。両エントリポイントは、バイトなしで成功を返すプロバイダに対してEHPDFCMSErrorを上げます
プロバイダの署名がファイルのどこに着地するか
プロバイダを実際のPDFバイトにつなぐ関数が2つあります。HPDFCMSBuildSignedDataWithProviderは、よそで計算した文書ダイジェストから分離型CMSを組み立てます。ダイジェスト長はOptions.DigestAlgorithmと一致しなければならず、SHA-384の設定は48バイトを期待し、32バイトのSHA-256ダイジェストは例外で拒否します。HPDFCMSSignPDFStreamWithProviderはAddSignedSignatureFieldがレイアウトしたプレースホルダに署名し、両方の/ByteRangeウィンドウをハッシュし、署名属性の上の署名をプロバイダに求め、16進の結果を/Contentsへパッチします。CMSがプレースホルダを超えて育つと、呼び出しは例外を上げ、何かを切り詰める代わりに出力ストリームの未署名プレースホルダを復元します
レイアウトはPFXベースの署名経路が書くのと同じものです。16進の署名ウィンドウをスキップする2つの範囲です。検証器はすべてのファイルにその形を要求します。範囲の間の隙間がこの署名の/Contents文字列ちょうどを含み他に何もないことを検査し、そうでなければsvInvalidByteRangeを報告します。PKCS#11トークン経由で署名された文書は、だからPFXファイルから署名されたものと同じ署名検証コードで検証されます。アルゴリズム選択の上に座るプロファイル規則はDelphiでのPAdESベースライン署名で、PKCS#11変換が扱うECDSAエンコードの罠はECDSA CMS検証とP1363署名形式で扱っています
読み手を置き去りにしない耐量子署名の展開
ML-DSAを実装しないPDFリーダーはそのような署名を検証不能として報告し、文書を見ている人には緑のチェックではなく警告が見えます。だから移行はスケジュールの問題です。今日のバリデータが判定する署名はRSAかECDSAのまま保ちます。方針が耐量子の証拠を要求する場所では、EnsureMLDSAExtensionsで拡張を宣言し、古典的な署名の隣にML-DSA対応の署名者による2つ目の署名を足します。その署名は自分のFIPS 204署名者から来ます。HotPDFのCMSビルダーはML-DSA署名を作らず、ライブラリの役割はそれを検証することだからです。文書を消費するシステムが検証できるようになったら、初めて主署名を動かします
その計画で、HotPDFはDelphiから制御できる両端をカバーします。CMSビルダーは上記のどのプロバイダ経由でもRSA PKCS#1 v1.5、RSA-PSS、ECDSA、Ed25519、Ed448で署名し、検証器は1回の呼び出しでML-DSA、EdDSA、古典的アルゴリズムを検査し、実際に見つかったOIDを結果に記録します。HotPDFは外部PDFランタイムを要しないDelphiとC++Builder向けのネイティブVCL PDFコンポーネントで、署名と検証のコードは実行ファイルにコンパイルされます。エディション、署名機能セット、トライアルダウンロードはHotPDF Delphi PDF component pageにあります