技術記事

HotPDF CSCリモート署名:DelphiでのクラウドPDF署名

HotPDFは、リモートのCloud Signature Consortium(CSC)サービスが握る秘密鍵でPDF文書に署名します。THPDFCSCSignatureProviderという署名プロバイダーがCSC APIを駆動します。credential info、認可、signatures/signHash、ポーリング。HTTPトランスポートとOAuthアクセストークンは、あなたのDelphiアプリケーションが供給します。鍵がサービスのHSMを離れることはありません

これが、qualifiedな署名鍵を手に入れる、ますます唯一の方法になっています。トラストサービスプロバイダーが渡すのはCSCエンドポイントとOAuthクライアントであって、PFXファイルやUSBトークンではありません。だからCNGとCAPIを通したWindows証明書ストア署名のように、ローカルの証明書ストアへ読み込むものが存在しません。素朴な統合は予想通りに失敗します。signHash呼び出しがタイムアウトし、リトライが同じ契約書に2回署名する。あるいは40件の請求書バッチが、ハッシュごとに個別に認可されたせいで、40回のワンタイムパスワードを引き起こす。プロバイダーがやっていることの大半は、この2つの失敗への防御です

HotPDFがHTTPをアプリケーションに任せるのはなぜか

トランスポートこそ、デプロイごとに違う場所だからです。プロキシー、TLSピンニング、クライアント証明書、企業のOAuthボールト、ログポリシーは、みなHTTP層に住んでいます。だからTHPDFCSCSignatureProviderはプロトコルの状態を統制し、リクエストごとにTHPDFCSCTransport関数を呼びます。プロバイダーはTHPDFCSCTransportRequestを渡してきます。Method(常にPOST)、ServiceBaseURL+エンドポイントパスから組み立てられた完全なURL、準備済みのAuthorization bearerヘッダー、ContentType、JSONのBody、IdempotencyKey、Attempt番号、MaxResponseBytes。あなたはStatusCode、Body、RetryAfterMSでTHPDFCSCTransportResponseを埋め、ctsSuccess、ctsTemporaryFailure、ctsPermanentFailure、ctsCancelledのどれかを返します

HotPDFのCSCトランスポート境界の図。THPDFCSCSignatureProviderがプロトコルを統制し、あなたのコードへPOSTメソッド、完全なURL、準備済みのAuthorization bearerヘッダー、JSONボディ、IdempotencyKey、試行番号を持つTHPDFCSCTransportRequestを渡します。あなたはStatusCode、Body、RetryAfterMSと4つのcts状態値のどれかを返し、鍵はHSMを離れません
プロバイダーはステータスコードを自分で分類します。だから、返ってきた503をpermanent failureに変えるトランスポートは、リトライロジックを黙って無効化します。プロキシーとTLSポリシーは、あなたが所有するコードの中に留まります
uses
  System.Net.HttpClient, System.Net.URLClient, HPDFSignatureProvider,
  HPDFCSCSignatureProvider;

function MakeCSCTransport(Client: THTTPClient): THPDFCSCTransport;
begin
  Result :=
    function(const Request: THPDFCSCTransportRequest;
      out Response: THPDFCSCTransportResponse): THPDFCSCTransportStatus
    var
      Body: TStringStream;
      Reply: TMemoryStream;
      Headers: TNetHeaders;
      HttpResp: IHTTPResponse;
    begin
      Response := Default(THPDFCSCTransportResponse);
      Body := TStringStream.Create(string(Request.Body), TEncoding.UTF8);
      Reply := TMemoryStream.Create;
      try
        Headers := [TNameValuePair.Create('Authorization', string(Request.Authorization)),
                    TNameValuePair.Create('Content-Type', string(Request.ContentType))];
        if Request.IdempotencyKey <> '' then  // ヘッダー名はサービスのドキュメントどおりに
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // ソケットやDNSのトラブル:リトライ可能
        end;
        Response.StatusCode := HttpResp.StatusCode;   // 503はそのまま報告し、分類しない
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

暗記する価値のある唯一の規則:サーバーが実際に答えたときは、たとえ503でも、ctsSuccessを返す。プロバイダーはステータスコードを自分で分類します。429をctsPermanentFailureに変えるトランスポートは、下で述べるリトライロジックを黙って無効化します。コンストラクターは逆方向に厳格です。トランスポートが欠けている、CredentialIDが空、AccessTokenもトークンコールバックも供給されていない、budgetが範囲外、ServiceBaseURLがHTTPSでない、のときにEHPDFCSCSignatureProviderErrorを上げます。素のhttp://はAllowInsecureHTTPが付いたときだけ受け付けられます。テストリグ専用で、他の場所には出すべきものではありません

SADとは何か、HotPDFが1回の使用後に捨てるのはなぜか

THPDFCSCSignatureProviderはSignature Activation Data(SAD)を1回限りとして扱います。signatures/signHashが受理された瞬間、プロバイダーの状態から消されます。署名そのものが後から非同期ポーリングで届く場合でもです。SADは、署名者がこのハッシュを承認したことの、サービスへの証明です。メモリーに居残るSADは、間違った文書に使われるのを待っている認可です

THPDFCSCOptions.Defaultの既定値、つまりRequireSADとAutoAuthorizeがともにTrueのもとでは、プロバイダーはcredentials/infoを1回読み、THPDFCSCAuthenticationCallbackにauthData値(OTP、PIN、credentialのauthブロックが要求するもの何でも)を尋ね、credentials/authorizeを投稿します。200はSADを直接運び、202はcredentials/authorizeCheckでポーリングされるハンドルを運びます。MaxPollAttempts(60)回まで、PollIntervalMS(250 ms)間隔で。コールバックは最大32値を返せて、各値は256バイトまでの非空IDと4,096バイトまでの値を持ちます。signHashが受理される前にネットワークが切れた場合は、自動取得されたSADは保持されます。署名者に再度尋ねることなく、同じバッチをリトライできるようにするためです

HotPDFのSADライフサイクルの図。RequireSADとAutoAuthorizeのもとで、プロバイダーはcredentials/infoを1回読み、認証コールバックにOTPやPINの値を尋ね、credentials/authorizeを投稿し、答えが202ならcredentials/authorizeCheckを250 ms間隔で最大60回ポーリングし、signatures/signHashが受理された瞬間にSignature Activation Dataを消去します。受理前にネットワークが切れたときは、取得済みのSADを保持します
メモリーに居残るSADは、間違った文書に使われるのを待っている認可です。そしてオプション経由で渡された事前設定のSADは、単一ハッシュのリクエストにだけ使われます
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD、AutoAuthorize、非同期モード、リトライ2回
  Options.ServiceBaseURL := 'https://csc.example.com/csc/v2';
  Options.CredentialID := 'contracts-signing-01';
  Options.ClientData := 'invoice-run-2026-09';

  Provider := THPDFCSCSignatureProvider.Create(Options, MakeCSCTransport(HttpClient),
    function(ForceRefresh: Boolean; const OperationIdentifier: AnsiString;
      out AccessToken: AnsiString; out ExpiresAtUTC: TDateTime): THPDFSignatureProviderStatus
    begin
      // あなたのOAuthクライアント。サービスが401を答えたあとはForceRefreshがTrue
      if not TokenVault.Acquire(ForceRefresh, AccessToken, ExpiresAtUTC) then
        Exit(spsProviderError);
      Result := spsValid;
    end,
    function(const CredentialID, CredentialInfoJSON, OperationIdentifier: AnsiString;
      out Values: THPDFCSCAuthenticationValues): THPDFSignatureProviderStatus
    var
      Otp: string;
    begin
      if not AskSignerForOtp(Otp) then   // あなたのUI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Options.SADで自分で渡すSADは、別の振る舞いをします。意図的な別の振る舞いです。HotPDFには、それがどのハッシュのために発行されたか分からないので、プロバイダーは事前設定のSADを単一ハッシュのリクエストにしか使いません。AutoAuthorizeを切ったバッチでは、プロバイダーは当て推量の代わりに「CSC SAD is not pinned to the requested hash batch」で失敗します

SignHashBatchは1つの認可で多数の文書にどう署名するか

SignHashBatchは、最大MaxBatchSignatures(64)個のダイジェストに対して、credentials/authorizeを1回とsignatures/signHashを1回送り、両方のボディを同じ配列から組み立てます。つまりnumSignaturesと、hashesの順序と、hashAlgorithmOIDが、2つの呼び出しで同一になります。この一致こそ、CSCのマルチサインモデルが要求するものです。単一ハッシュのSignメソッドを40回ループすれば、認可は40個になります。食い違うauthorizeとsignHashを送れば、サービスはSADを間違ったバッチに対して消費するかもしれません

ネットワークトラフィックの前に、プロバイダーはバッチを検証します。すべてのリクエストは、ダイジェストOIDを持つ、1〜1,024バイトのダイジェスト(sikDigest)でなければならず、すべてのリクエストは1つの署名アルゴリズムOIDと1つのダイジェストOID、そしてRSASSA-PSSでは1つのソルト長を共有しなければなりません。マルチハッシュのバッチはさらにcredentials/infoを読み、credentialのmultisign値がバッチより小さければspsUnsupportedを返します。そしてSADはバッチフィンガープリントに固定されます。バージョンラベル、カウント、リクエストごとのアルゴリズムOID、ダイジェストOID、アルゴリズム、ソルト長、ダイジェストバイト列(それぞれ長さプレフィックス付き)にわたるSHA-256です。2つのハッシュを入れ替えれば、新しい認可を必要とする別のバッチです

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests:あなたが計算したSHA-256値
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // AlgorithmOIDが空ならsignAlgoを導出
    Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
    Requests[I].InputKind := sikDigest;
    Requests[I].Input := Digests[I];
  end;
  Status := Provider.SignHashBatch(Requests, 'invoices-2026-09-25-a', Signatures);
  if Status <> spsValid then
    raise Exception.CreateFmt('CSC batch failed (HTTP %d): %s',
      [Provider.LastHTTPStatus, Provider.LastError]);
  // Signatures[I]はDigests[I]に対応する。カウントはリクエストに対して検証済み
end;

RSASSA-PSSでは、プロバイダーはさらにsignAlgoParamsを送ります。ハッシュアルゴリズム、MGF1、ソルト長を持つ、base64のDER RSASSA-PSS-params構造です。これを組み立てることはOIDのエンコードを意味し、v2.748.5はその一角を直しました。X.690 §8.19.4は最初の2つのアークを1つの値へ折りたたみます(40 × first + second)。そして2ルートの下では、39を超える第2アークがその値を127を超えて押し上げ、base-128のマルチバイト形式が必要になります。旧ビルドはこれを適用していませんでした。SHA-2のOIDはどれも影響を受けません。"2.16"は96へ折りたたまれます。しかし不正なOIDはいまや、EConvertErrorの代わりに、プロバイダー自身のエラーを上げます

リトライされたリクエストが2つ目の署名を作らないのはなぜか

THPDFCSCSignatureProviderは、リトライ可能な呼び出しが決定論的なidempotencyキーを運ぶようにし、完了した結果をキャッシュします。レスポンスを失ったあとのリトライは、HSMに新しい署名を頼む代わりに、元の署名を返します。キーはcsc-に続いて、操作識別子とフェーズのhex SHA-256で、フェーズはauthorizeとsignHashの両方についてバッチフィンガープリントを埋め込みます。切り詰める代わりにハッシュするのが重要です。プレフィックスを共有する2つの長い操作IDは、切り詰めでは衝突します。固定長のコンテンツアドレス型キーなら、試行をまたいで一意かつ安定です

共有リクエスト経路のリトライポリシーは、意図的に狭くしています:

  • HTTP 401は、アクセストークンコールバックを通してきっかり1回のトークンリフレッシュを強制し、アクセストークンコールバックが割り当てられていれば、リクエストは1回だけ繰り返されます。2回目の401は最終です
  • その他の4xxレスポンスとctsPermanentFailureは、spsProviderErrorで呼び出しを終え、サービスのerror_descriptionはLastErrorに入ります
  • 408、429、5xx、ctsTemporaryFailureはRetryLimit(既定2)までリトライされ、Retry-AfterかRetryBaseDelayMS × 2attempt(基本100 ms)を待ち、MaxRetryAfterMS(5,000 ms)で頭打ちです
  • 待ち時間はCancelを確認する25 msのスライスで走るので、中止したユーザーが5秒のバックオフを座り続けることはありません
  • signHashがリトライされるのはEnableIdempotencyが有効な間だけです。切ると、送信後のタイムアウトは最終です。キーがすでに使われたかどうか、誰にも分からないからです
HotPDFのリトライポリシーの図。リトライ可能な呼び出しはどれも、操作識別子とフェーズからハッシュされた決定論的なcsc- idempotencyキーを運び、HTTP 401はきっかり1回のトークンリフレッシュを強制し、その他の4xxの答えはspsProviderErrorで終わり、408、429、5xxや一時的なトランスポート失敗は、Retry-Afterか5,000 msで頭打ちの指数バックオフを待ちながら、RetryLimitの2までリトライされます
完了したバッチは操作識別子、credential、フィンガープリントでキャッシュされ、非同期モードでは、保存されたresponseIDのおかげで、繰り返し呼び出しはハッシュを再送する代わりにポーリングを再開します

非同期署名(operationMode "A"、既定)はもう1つの防御を加えます。responseIDはsignatures/signPollingをポーリングする前に保存されるので、同じ操作識別子での繰り返し呼び出しは、再送する代わりにポーリングを再開します。完了したバッチは、操作識別子、credential、フィンガープリントでキー付けされたキャッシュに座り、MaxOperationCacheEntries(128)で頭打ち、ディープコピーとして返されます。このキャッシュはプロバイダーインスタンスの中に住み、再起動を生き延びません。idempotencyキーは生き延びます。導出されたものであってランダムではないからです。だから操作識別子を再利用する再起動されたプロセスは、同じキーを送ります。サービスがそれで重複排除するかどうかは、サービスの約束であってHotPDFの約束ではありません

CSC署名をPDFへ入れるには

プロバイダーを、GetCertificateChainからのend-entity証明書とともにHPDFCMSSignPDFStreamWithProviderへ渡します。HotPDFがCMS SignedDataを組み立て、プロバイダーがsigned attributesのダイジェストに署名します。入力PDFには、THPDFPage.AddSignedSignatureFieldが書く/ByteRangeと/Contentsプレースホルダーが必要です。HotPDFのPAdES署名ワークフローのとおりです。そしてプロバイダーモデルは、ML-DSAとEdDSA向けのHotPDFプラグ可能署名プロバイダーが扱うのと同じものです

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSCはend-entity証明書を最初に列挙する
  if Length(Chain) = 0 then
    raise Exception.Create('Credential returned no certificate');

  SignOpts := HPDFCMSDefaultOptions(palBaseline_B_B);
  SignOpts.DigestAlgorithm := cmsdaSHA256;
  SignOpts.SignatureScheme := cmsRSAPKCS1v15;

  Src := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  Dst := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    if not HPDFCMSSignPDFStreamWithProvider(Src, Dst, Chain[0], Provider, '', SignOpts) then
      raise Exception.Create('PDF signing failed');
  finally
    Dst.Free;
    Src.Free;
  end;
end;

/Contentsプレースホルダーのサイズ決定はEstimateSignatureSizeを通ります。EstimatedSignatureBytesを設定していればそれを返し、さもなければcredentialの鍵長からのRSAモジュラスサイズを返します。ECDSAではEstimatedSignatureBytesを自分で設定してください。さもなければ推定はspsUnsupportedを報告します。自動サイズの署名バリアントは、プレースホルダーが小さすぎると判明したときに再署名します。そしてそれは、spcSafeSignRetryを告知するプロバイダーにだけです。"THPDFCSCSignatureProvider"がこれをするのは、EnableIdempotencyが有効な間だけです。PAdES-B-Tのワークフローでは、TimestampDigestが、同じサービスからsignatures/timestamp経由でタイムスタンプトークンを要求します。MaxTimestampBytes(1 MB)で頭打ちです

CSCプロバイダーがやらないこと

メッセージには署名しません。ダイジェストだけです。純モードのEd25519とEd448は、signed-attributesメッセージ全体(sikMessage)をプロバイダーへ渡しますが、バッチバリデーターはそれを不正として拒否します。signHashは定義上ハッシュベースだからです。プロバイダーユニットは、無名メソッドの代わりにプレーンな関数型を使ってFree Pascalでもコンパイルできます。しかしプロバイダー駆動のCMSビルダーは現状FPCではraiseするので、CSC署名をPDFへ埋め込むのはDelphiの経路です

ポリシーも決めません。CredentialInfoはキー状態、証明書状態、認可モード、SCALレベル、マルチサイン上限を報告しますが、プロバイダーは、無効化されたキーやSCAL1のcredentialを、自分から拒否しません。署名者にOTPプロンプトを見せる前に、それらを確認してください。そして1つのプロバイダーインスタンスは1度に1バッチへ署名します。SignHashBatchは内部で直列化されるので、2つのスレッドが1つのSADを取り合うことはありません。つまりスループットは、ワーカースレッドでプロバイダーを共有するのではなく、バッチングから来ます。結果の署名がqualifiedかどうかは、ハッシュを運んだライブラリーではなく、トラストサービスとそのcredential次第です

CSCプロバイダー、CMSとPAdESのビルダー、そしてローカルとPKCS#11のプロバイダーは、HotPDF Delphi PDF componentにすべて同梱されています