技術記事

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

リモート署名サービスへのsignatures/signHash要求は、HSMがすでに署名を作り終えた後でタイムアウトでき、クライアントは自分がその線のどちら側に着地したか分かりません。盲目的にリトライすると、1つの承認が同じ契約書に2つの署名を生みかねません。リトライを拒むと、夜間バッチは1つの503で死にます。40件の請求書を1ハッシュずつ署名すると、署名者は40回のワンタイムパスワードを打ち込みます

CSC(Cloud Signature Consortium)統合はどれも、この両方を解決しなければなりません。qualified鍵を発行するトラストサービスプロバイダーは、PFXファイルやUSBトークンの代わりに、CSC APIエンドポイントとOAuthクライアントを配るようになってきています。CNGとCAPI経由のWindows証明書ストア署名のように、ローカルストアへインポートするものが何もないのです。鍵はプロバイダーのHSMの中にとどまり、すべての署名はHTTPSでの1往復です

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

HotPDFはTHPDFCSCSignatureProvider経由でリモートCSCサービスを通して署名します。この署名プロバイダーはCSC v2プロトコル(credential info、authorization、signatures/signHash、ポーリング)を走らせ、DelphiコードはHTTPトランスポートとOAuthトークンを所有します。プロキシー、TLSピン留め、クライアント証明書、トークンヴォールト、リクエストログはすべてHTTPレイヤーに住み、配備ごとに違います

リクエストごとに、プロバイダーはTHPDFCSCTransportRequestを組み立て、あなたのTHPDFCSCTransport関数を呼びます。レコードは完全なURL、準備済みのAuthorization bearerヘッダー、JSONのBody、IdempotencyKey、OperationIdentifier、Attempt番号を運びます。あなたの関数は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を恒久失敗に変えるトランスポートは、リトライロジックを黙って無効化します。プロキシーとTLSポリシーは自分のコードの中にとどまります

RTLのTHTTPClientの上に組んだトランスポートは40行ほどで済みます。ボディは生バイトとして出ていくので、JSONはコードページ変換を通りません

uses
  System.SysUtils, System.Classes, 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, Reply: TMemoryStream;
      Headers: TNetHeaders;
      HttpResp: IHTTPResponse;
    begin
      Response := Default(THPDFCSCTransportResponse);
      Body := TMemoryStream.Create;
      Reply := TMemoryStream.Create;
      try
        if Request.Body <> '' then
          Body.WriteBuffer(Request.Body[1], Length(Request.Body));
        Body.Position := 0;
        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、TLS、ソケットのトラブル:リトライ可能
        end;
        Response.StatusCode := HttpResp.StatusCode; //  429と503は手を加えず素通りさせる
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        //  Retry-Afterは秒単位。HTTP-dateの値はここでは0へフォールバックする
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

覚えるべき規則は1つです。サーバーが答えたら、503であってもctsSuccessを返す。プロバイダーはステータスコードを自分で分類するのであり、429をctsPermanentFailureへ写すトランスポートは、後述のリトライロジックをスイッチオフします。MaxResponseBytes(デフォルト2 MB)より大きいボディは、トランスポートが何を返そうとspsMalformedで拒否されます

コンストラクターは逆方向に厳格です。トランスポートがない、ServiceBaseURLかCredentialIDが空、AccessTokenもトークンコールバックも供給されていない、予算が範囲外、URLがHTTPSでない、のときEHPDFCSCSignatureProviderErrorを上げます。素のhttp://はAllowInsecureHTTPが設定されたときだけ通り、そのフラグはローカルのモックサービスへ向けたテスト装置に属します

SADとは何で、なぜ1回限りなのか

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

THPDFCSCOptions.Defaultのデフォルト(RequireSADとAutoAuthorizeが両方True)では、プロバイダーはcredentials/infoを1回ロードし、credentialのauthブロックが要求するauthData値を、たいていはOTPかPIN、あなたのTHPDFCSCAuthenticationCallbackに尋ね、credentials/authorizeを投稿します。200の答えはSADを直接運びます。202はハンドルを運び、プロバイダーはサービスがRetry-Afterを送らない限り、PollIntervalMS(250 ms)間隔でMaxPollAttempts(60)回までcredentials/authorizeCheckをポーリングします。コールバックは最大32個の値を返せて、それぞれに最大256バイトの空でないIDと最大4,096バイトの値が付きます

消去は受理の後に起こります。signHashが受理される前にネットワークが切れた場合、自動取得されたSADは同じバッチに結び付いたまま残るので、リトライは2回目のOTPを尋ねずに出ていきます。署名者は1回承認し、プロバイダーはその承認を1回使います

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
      //  TokenVaultは自分の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);

トークンコールバックは、キャッシュされたトークンがないとき、または報告した有効期限からTokenRefreshSkewSeconds(30)以内に入ったときに発火します。ExpiresAtUTCのゼロは、401がリフレッシュを強制するまでトークンが再利用されることを意味します

自分でOptions.SAD経由で渡すSADは、意図的に別の振る舞いをします。そのSADがどのハッシュをカバーするかをプロバイダーに伝えるものは何もないので、事前設定のSADは単一ハッシュのリクエストにだけ使われます。AutoAuthorizeが入ったマルチハッシュバッチでは事前設定値を捨ててバッチを認可し、AutoAuthorizeが切れていると、当て推量する代わりに「CSC SAD is not pinned to the requested hash batch」で失敗します。OAuthスコープだけ認可しSADを発行しないサービスは、RequireSADをFalseにして動きます。このときSADメンバーはsignHashボディから外されます

1回のOTPプロンプトで40件の請求書に署名する

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

検証はどのネットワークトラフィックの前にも走ります。すべてのリクエストは、ダイジェストOID付きの1から1,024バイトのダイジェスト(sikDigest)でなければならず、全リクエストは1つの署名アルゴリズム、1つのダイジェストOID、RSASSA-PSSなら1つのソルト長を共有しなければなりません。マルチハッシュバッチはさらにcredentials/infoをロードし、credentialのmultisign値がバッチサイズより小さいとspsUnsupportedを返します。認可はその後、リクエストリストの順序敏感なSHA-256フィンガープリントに結び付けられるので、2つのハッシュを入れ替えると別のバッチになり、新しい承認が要ります

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  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;

  //  IDは自分の実行記録から導出する。呼び出しごとの新鮮なGUIDからは決してしない
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    //  同じOpId:完了済みバッチはキャッシュから返り、保留中の
    //  非同期ジョブはポーリングを再開し、未送信リクエストは同じ鍵を再利用する
    Status := Provider.SignHashBatch(Requests, OpId, 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構造を足します。X.690 §8.19.4は最初の2つのOIDアークを1つの値へ折り畳み(40×第1+第2)、2ルートの下では39を越える第2アークが127を越えさせ、そこでbase-128のマルチバイト形式が要ります。SHA-2のOIDはこの経路を試しません。2.16は96へ折り畳まれるからです。だからエンコーダはすべてのSHA-2テストを通しながら間違っていられます。プロバイダーはマルチバイト形式を書き、不正なOIDをEConvertErrorでなく自分のEHPDFCSCSignatureProviderErrorとして報告します

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

THPDFCSCSignatureProviderはリトライ可能な呼び出しごとに決定論的なidempotencyキーを与え、完了した結果をキャッシュするので、失われた応答の後のリトライはHSMに新しい署名を頼む代わりに元の署名を返します。キーはcsc-の後に操作識別子とプロトコルフェーズのhex SHA-256が続き、認可とsignHashではフェーズにバッチフィンガープリントが入ります。切り詰めでなくハッシュするので、キーは固定長に保たれ、共通接頭辞を持つ2つの長い操作IDが衝突しません

共有リクエスト経路のリトライポリシーは狭いです

  • HTTP 401はアクセストークンコールバックを通して正確に1回のトークンリフレッシュを強制し、リクエストは1回繰り返されます。2つ目の401は最終であり、トークンコールバックがなければ1つ目が最終です
  • その他の4xx応答とctsPermanentFailureは呼び出しをspsProviderErrorで終わらせ、サービスのerror_description(またはerror)がLastErrorに着地します
  • 408、429、5xx、ctsTemporaryFailureはRetryLimit(デフォルト2)までリトライされ、Retry-Afterか、試行ごとに倍化するRetryBaseDelayMS(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"、デフォルトで、validity_periodはAsyncValidityPeriodMSの60,000 ms)はもう1つのガードを足します。responseIDはプロバイダーがsignatures/signPollingのポーリングを始める前に保存されるので、同じ操作識別子での繰り返し呼び出しはハッシュを再送信する代わりに保留中のジョブを拾います。完了したバッチはプロバイダーインスタンスごとに、MaxOperationCacheEntries(128)までキャッシュされ、再起動後そのキャッシュは消えています。idempotencyキーは生き残ります。導出されるものであってランダムでないからです。操作識別子を再利用する再起動済みプロセスは同じキーを送り、サービスがそれで重複排除するかどうかはサービスの契約の一部です

このすべては操作識別子にかかっており、このパラメータは空のままにされがちです。SignHashBatchへ''を渡すと、プロバイダーはリクエスト上のOperationIdentifierへフォールバックし、それから新しいGUIDへフォールバックします。呼び出しごとの新しいGUIDは、自分のリトライループが毎回新しい操作を始めることを意味します。新しいキャッシュスロット、新しいidempotencyキー、そして場合によっては2回目のOTPプロンプトです。Provider.Cancelの標的もなくなります。自分のコードはGUIDを見ていないのですから。識別子はバッチ番号やドキュメントIDのような、すでに保存しているものから導出し、EnableIdempotencyはデフォルトのTrueのままにしてください。サービスがヘッダーを拒否するなら、オプションを切るのでなくトランスポートの中で外してください

CSC署名をPDFへ埋め込む

プロバイダーを、GetCertificateChainのend-entity証明書と一緒にHPDFCMSSignPDFStreamWithProviderへ渡すと、HotPDFはCMS SignedDataを組み立て、プロバイダーはsigned attributesのダイジェストに署名します。入力PDFには、HotPDFのPAdES署名ワークフローとまったく同じように、THPDFPage.AddSignedSignatureFieldが書く/ByteRangeと/Contentsプレースホルダーが要り、プロバイダーモデルは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;

プレースホルダーのサイズ合わせには、EstimateSignatureSizeがあります。設定していればEstimatedSignatureBytesを返し、そうでなければcredentials/infoの鍵長から導出したRSA係数サイズを返します。ECDSA鍵ではspsUnsupportedを返し、プレースホルダー推定器は証明書へフォールバックするので、EstimatedSignatureBytesを自分で設定すると予測可能な結果になります。HPDFCMSSignPDFStreamWithProviderAutoSizeは、プレースホルダーが小さすぎると分かったときに再構築と再署名を行い、それはspcSafeSignRetryを宣伝するプロバイダーに対してだけです。THPDFCSCSignatureProviderはEnableIdempotencyが入っている間それを宣伝します。このデフォルトを放置するもう1つの理由です

PAdES-B-Tでは、TimestampDigestが同じサービスへ、signatures/timestamp経由でRFC 3161トークンを尋ねます。任意のnonceと、MaxTimestampBytes(1 MB)のサイズ上限付きです

自分のコードの中にとどまるポリシー決定

プロバイダーはダイジェストに署名します。pureモードのEd25519とEd448は、署名プロバイダーにsigned-attributesメッセージ全体(sikMessage)を渡し、バッチバリデーターはそれを不正として拒否します。signHashは定義によりハッシュベースだからです。Free Pascalの下では、プロバイダーユニットは匿名メソッドの代わりに素の関数型でコンパイルされるので、SignHashBatchとTimestampDigestはそこでも呼び出せます。結果をHPDFCMSSignPDFStreamWithProvider経由でPDFへ埋め込むのはDelphiの経路です

プロバイダーはポリシーもあなたに任せます。CredentialInfoはKeyStatus、CertificateStatus、AuthorizationMode、SCAL、MultiSignaturesを報告しますが、無効化された鍵やSCAL1 credentialを拒否するかはあなたの判断です。それらのフィールドはRefreshCredentialInfoの直後、署名者がOTPプロンプトを見る前に確認してください。署名者がコードを打ち込んだ後のサービスからの拒否は、最初の明確なエラーより悪い体験です

1つのプロバイダーインスタンスは1度に1つのバッチを走らせます。SignHashBatch、RefreshCredentialInfo、TimestampDigestはロックを共有し、2つのスレッドが1つのSADを奪い合えないからです。スループットはより大きなバッチから来ます。credentialのmultisign上限までです。結果の署名がqualifiedと数えられるかは、トラストサービスとそれが発行したcredentialが決めます

CSCプロバイダーは、CMSとPAdESビルダー、ローカルとPKCS#11の署名プロバイダーと並んでHotPDF Delphi PDF componentに同梱されます。リモート、トークンベース、ファイルベースの署名はすべて、同じPDF署名呼び出しへ供給されます