Artikel Teknis

Remote Signing CSC HotPDF: Signature PDF Cloud di Delphi

HotPDF menandatangani dokumen PDF dengan private key yang dipegang layanan Cloud Signature Consortium (CSC) jarak jauh lewat THPDFCSCSignatureProvider, signature provider yang menggerakkan CSC API — credential info, otorisasi, signatures/signHash, dan polling — sementara aplikasi Delphi Anda menyediakan transport HTTP dan OAuth access token. Key-nya tak pernah meninggalkan HSM milik layanan

Itu makin hari makin menjadi satu-satunya cara mendapat signing key yang qualified. Trust service provider menyerahkan endpoint CSC dan OAuth client, bukan file PFX atau token USB, jadi tak ada yang bisa dimuat ke certificate store lokal sebagaimana dilakukan penandatanganan cert store Windows lewat CNG dan CAPI. Integrasi yang naif gagal dengan cara yang bisa diduga: panggilan signHash timeout dan retry-nya menandatangani kontrak yang sama dua kali, atau satu batch empat puluh faktur memicu empat puluh one-time password karena setiap hash diotorisasi terpisah. Sebagian besar apa yang dilakukan provider ini adalah bertahan dari dua kegagalan itu

Kenapa HotPDF menyerahkan HTTP ke aplikasi Anda?

Karena transport justru tempat setiap deployment berbeda. Proxy, TLS pinning, client certificate, vault OAuth korporat, dan kebijakan logging semuanya tinggal di lapisan HTTP, jadi THPDFCSCSignatureProvider mengorkestrasi state protokol dan memanggil fungsi THPDFCSCTransport untuk setiap request. Provider menyerahkan THPDFCSCTransportRequest ke Anda dengan Method (selalu POST), URL lengkap yang dibangun dari ServiceBaseURL plus path endpoint-nya, header bearer Authorization yang siap pakai, ContentType, Body JSON, IdempotencyKey, nomor Attempt, dan MaxResponseBytes. Anda mengisi THPDFCSCTransportResponse dengan StatusCode, Body, dan RetryAfterMS, lalu mengembalikan salah satu dari ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure, atau ctsCancelled

Diagram batas transport CSC HotPDF: THPDFCSCSignatureProvider mengorkestrasi protokol dan menyerahkan THPDFCSCTransportRequest ke kode Anda dengan method POST, URL lengkap, header bearer Authorization siap pakai, body JSON, IdempotencyKey dan nomor attempt, dan Anda mengembalikan StatusCode, Body, RetryAfterMS plus salah satu dari empat nilai status cts sementara key tak pernah meninggalkan HSM
Provider mengklasifikasikan status code sendiri, jadi transport yang mengubah 503 yang terjawab menjadi kegagalan permanen diam-diam mematikan logika retry, sementara proxy dan kebijakan TLS tetap berada di kode milik Anda
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  // nama header sebagaimana didokumentasikan layanan Anda
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // masalah socket atau DNS: bisa di-retry
        end;
        Response.StatusCode := HttpResp.StatusCode;   // laporkan 503 apa adanya, jangan diklasifikasi
        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;

Satu aturan yang layak dihafal: kembalikan ctsSuccess setiap kali server benar-benar menjawab, bahkan dengan 503. Provider mengklasifikasikan status code sendiri, dan transport yang mengubah 429 menjadi ctsPermanentFailure diam-diam mematikan logika retry yang dijelaskan di bawah. Constructor-nya ketat ke arah sebaliknya — ia melempar EHPDFCSCSignatureProviderError ketika transport hilang, CredentialID kosong, tak ada AccessToken maupun callback token yang diberikan, budget di luar rentang, atau ServiceBaseURL bukan HTTPS. http:// polos diterima hanya dengan AllowInsecureHTTP, yang tempatnya di test rig dan tak di mana pun selain itu

Apa itu SAD, dan kenapa HotPDF membuangnya setelah sekali pakai?

THPDFCSCSignatureProvider memperlakukan Signature Activation Data (SAD) sebagai sekali pakai: ia dibersihkan dari state provider begitu signatures/signHash diterima, bahkan ketika signature-nya sendiri datang belakangan lewat polling asinkron. SAD adalah bukti dari layanan bahwa signer menyetujui hash-hash khusus ini, dan SAD yang menganggur di memori adalah otorisasi yang menunggu dibelanjakan untuk dokumen yang salah

Dengan bawaan dari THPDFCSCOptions.Default — RequireSAD dan AutoAuthorize keduanya True — provider memuat credentials/info sekali, meminta nilai authData dari THPDFCSCAuthenticationCallback Anda (OTP, PIN, apa pun yang diminta blok auth milik credential), dan mengirim POST credentials/authorize. Jawaban 200 membawa SAD langsung; jawaban 202 membawa handle yang di-polling lewat credentials/authorizeCheck sampai MaxPollAttempts (60) kali pada PollIntervalMS (250 ms). Callback boleh mengembalikan paling banyak 32 nilai, masing-masing dengan ID tak kosong sampai 256 byte dan nilai sampai 4.096 byte. Kalau jaringan putus sebelum signHash diterima, SAD yang diperoleh otomatis dipertahankan supaya batch yang sama bisa di-retry tanpa menanyakan signer lagi

Diagram siklus hidup SAD HotPDF: dengan RequireSAD dan AutoAuthorize provider memuat credentials/info sekali, meminta nilai OTP atau PIN dari callback autentikasi, mengirim POST credentials/authorize, mem-polling credentials/authorizeCheck sampai 60 kali pada 250 ms ketika jawabannya 202, dan membersihkan Signature Activation Data begitu signatures/signHash diterima, mempertahankan SAD yang sudah diperoleh saat jaringan putus sebelum penerimaan
SAD yang menganggur di memori adalah otorisasi yang menunggu dibelanjakan untuk dokumen yang salah, dan SAD preset yang dikirim lewat options hanya dipakai untuk request satu hash
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, mode asinkron, 2 retry
  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 client Anda; ForceRefresh bernilai True setelah layanan menjawab 401
      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 Anda
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

SAD yang Anda kirim sendiri lewat Options.SAD berperilaku berbeda, dan memang sengaja. HotPDF tak mungkin tahu SAD itu diterbitkan untuk hash mana, jadi provider memakai SAD preset hanya untuk request satu hash. Untuk batch dengan AutoAuthorize dimatikan, provider gagal dengan "CSC SAD is not pinned to the requested hash batch" alih-alih menduga-duga

Bagaimana SignHashBatch menandatangani banyak dokumen dengan satu otorisasi?

SignHashBatch mengirim satu credentials/authorize dan satu signatures/signHash untuk sampai MaxBatchSignatures (64) digest, dan membangun kedua body dari array yang sama sehingga numSignatures, urutan hashes, dan hashAlgorithmOID identik di kedua panggilan. Kecocokan itulah yang disyaratkan model multisign CSC. Loop method Sign satu hash empat puluh kali dan Anda kebagian empat puluh otorisasi; kirim authorize dan signHash yang tak cocok dan layanannya bisa menghabiskan SAD untuk batch yang salah

Sebelum trafik jaringan apa pun, provider memvalidasi batch-nya. Setiap request harus berupa digest (sikDigest) berukuran 1 sampai 1.024 byte dengan OID digest, dan semua request harus berbagi satu OID algoritma signature, satu OID digest, dan, untuk RSASSA-PSS, satu panjang salt. Batch multi-hash juga memuat credentials/info dan mengembalikan spsUnsupported ketika nilai multisign milik credential lebih kecil dari batchnya. SAD lalu dipatok ke fingerprint batch — SHA-256 atas label versi, hitungannya, dan per request: OID algoritma, OID digest, algoritma, panjang salt, dan byte digest, masing-masing berawalan panjang. Tukar dua hash dan itu batch berbeda yang butuh otorisasi segar

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: nilai SHA-256 yang Anda hitung
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo diturunkan saat AlgorithmOID kosong
    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] milik Digests[I]; hitungannya sudah dicek terhadap request
end;

Untuk RSASSA-PSS provider juga mengirim signAlgoParams, struktur DER RSASSA-PSS-params ber-base64 dengan algoritma hash, MGF1, dan panjang salt. Membangunnya berarti mengenkode OID, dan versi 2.748.5 memperbaiki satu sudut dari itu: X.690 §8.19.4 melipat dua arc pertama menjadi satu nilai (40 × arc pertama + arc kedua), dan di bawah akar 2, arc kedua di atas 39 mendorong nilai itu melewati 127, tempat ia butuh bentuk multi-byte basis-128 yang build sebelumnya tak terapkan. Tak ada OID SHA-2 yang terdampak — 2.16 melipat menjadi 96 — tapi OID yang malformed kini melempar error milik provider sendiri alih-alih EConvertError

Kenapa request yang di-retry tidak menghasilkan signature kedua?

THPDFCSCSignatureProvider membuat setiap panggilan yang bisa di-retry membawa idempotency key deterministik dan meng-cache hasil yang selesai, jadi retry setelah respons yang hilang mengembalikan signature aslinya alih-alih meminta yang baru dari HSM. Key-nya adalah csc- disusul hex SHA-256 dari identifier operasi dan phase-nya, dan phase itu menyematkan fingerprint batch untuk otorisasi maupun signHash. Meng-hash alih-alih memotong itu penting: dua ID operasi panjang yang berbagi prefiks akan bertabrakan di bawah pemotongan, sementara key beralamat konten berpanjang tetap unik dan stabil lintas percobaan

Kebijakan retry di jalur request bersama sengaja dibuat sempit:

  • HTTP 401 memaksa tepat satu refresh token lewat callback access-token, lalu request diulang sekali ketika callback access-token di-assign; 401 kedua bersifat final
  • Respons 4xx lainnya dan ctsPermanentFailure mengakhiri panggilan dengan spsProviderError, dan error_description milik layanan mendarat di LastError
  • 408, 429, 5xx, dan ctsTemporaryFailure di-retry sampai RetryLimit (bawaan 2), menunggu Retry-After atau RetryBaseDelayMS × 2attempt (basis 100 ms), dipatok MaxRetryAfterMS (5.000 ms)
  • Penungguan berjalan dalam irisan 25 ms yang memeriksa Cancel, jadi pengguna yang membatalkan tak perlu menunggu back-off lima detik
  • signHash hanya di-retry selama EnableIdempotency aktif; matikan itu dan timeout setelah pengiriman bersifat final, karena tak ada yang bisa memastikan key-nya sudah terpakai atau belum
Diagram kebijakan retry HotPDF: setiap panggilan yang bisa di-retry membawa idempotency key csc- deterministik yang di-hash dari identifier operasi dan phase, HTTP 401 memaksa tepat satu refresh token, jawaban 4xx lainnya berakhir dengan spsProviderError, dan 408, 429, 5xx atau kegagalan transport sementara di-retry sampai RetryLimit 2 sambil menunggu Retry-After atau backoff eksponensial yang dipatok 5.000 ms
Batch yang selesai di-cache berdasarkan identifier operasi, credential, dan fingerprint, dan dalam mode asinkron responseID yang tersimpan membuat panggilan ulang melanjutkan polling alih-alih mengirim ulang hash-nya

Penandatanganan asinkron (operationMode "A", bawaannya) menambah satu penjaga lagi: responseID disimpan sebelum mem-polling signatures/signPolling, jadi panggilan ulang dengan identifier operasi yang sama melanjutkan polling alih-alih mengirim ulang. Batch yang selesai menetap di cache berkunci identifier operasi, credential, dan fingerprint, dipatok MaxOperationCacheEntries (128) dan dikembalikan sebagai salinan dalam. Cache itu tinggal di instance provider dan tak selamat dari restart. Idempotency key-nya selamat, karena diturunkan alih-alih diacak, jadi proses yang di-restart dan memakai ulang identifier operasinya mengirim key yang sama — apakah layanannya mendeduplikasi atasnya adalah janji si layanan, bukan janji HotPDF

Bagaimana memasukkan signature CSC ke dalam PDF?

Kirimkan provider ke HPDFCMSSignPDFStreamWithProvider bersama sertifikat end-entity dari GetCertificateChain; HotPDF membangun CMS SignedData dan provider menandatangani digest dari signed attributes. PDF input-nya butuh placeholder /ByteRange dan /Contents yang ditulis THPDFPage.AddSignedSignatureField, persis seperti di alur kerja penandatanganan PAdES di HotPDF, dan model provider-nya adalah yang sama dengan yang dibahas di signature provider pluggable HotPDF untuk ML-DSA dan EdDSA

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC mendaftar sertifikat end-entity lebih dulu
  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;

Menentukan ukuran placeholder /Contents lewat EstimateSignatureSize, yang mengembalikan EstimatedSignatureBytes ketika Anda menyetelnya dan bila tidak berupa ukuran modulus RSA dari panjang key milik credential. Untuk ECDSA, setel EstimatedSignatureBytes sendiri, atau estimasinya melaporkan spsUnsupported. Varian penandatanganan auto-size menandatangani ulang ketika placeholder ternyata terlalu kecil, dan itu hanya ia lakukan untuk provider yang mengiklankan spcSafeSignRetry — yang dilakukan THPDFCSCSignatureProvider hanya selama EnableIdempotency aktif. Untuk alur kerja PAdES-B-T, TimestampDigest meminta timestamp token dari layanan yang sama lewat signatures/timestamp, dipatok MaxTimestampBytes (1 MB)

Apa yang tidak dilakukan provider CSC ini?

Ia tidak menandatangani pesan, hanya digest. Ed25519 dan Ed448 dalam mode pure menyerahkan seluruh pesan signed-attributes ke provider (sikMessage), dan validator batch menolaknya sebagai malformed, karena signHash memang berbasis hash sejak definisinya. Unit provider ter-compile di Free Pascal dengan tipe fungsi biasa menggantikan anonymous method, tapi builder CMS penggerak provider melempar exception di FPC saat ini, jadi menyematkan signature CSC ke PDF adalah jalur Delphi

Ia juga tak menentukan kebijakan. CredentialInfo melaporkan status key, status sertifikat, mode otorisasi, level SCAL, dan batas multisign, tapi provider tak akan menolak key yang dinonaktifkan atau credential SCAL1 dengan sendirinya — periksa itu sebelum Anda menampilkan prompt OTP kepada signer. Dan satu instance provider menandatangani satu batch pada satu waktu: SignHashBatch diserialisasi secara internal supaya dua thread tak bisa berebut satu SAD, artinya throughput datang dari batching, bukan dari berbagi provider lintas worker thread. Apakah signature hasilnya qualified bergantung pada trust service dan credential-nya, bukan pada library yang mengantarkan hashnya ke sana

Provider CSC, builder CMS dan PAdES, serta provider lokal dan PKCS#11 semuanya ikut terkirim di HotPDF Delphi PDF component