บทความเทคนิค

CSC remote signing ของ HotPDF: ลายเซ็น PDF บนคลาวด์ใน Delphi

HotPDF ลงนามเอกสาร PDF ด้วย private key ที่บริการ Cloud Signature Consortium (CSC) ระยะไกลถืออยู่ ผ่าน THPDFCSCSignatureProvider ซึ่งเป็น signature provider ที่ขับ CSC API — credential info, authorization, signatures/signHash และการ polling — ขณะที่แอปพลิเคชัน Delphi ของคุณจัด HTTP transport กับ OAuth access token ให้ คีย์ไม่เคยหลุดจาก HSM ของบริการ

ทางนี้กำลังกลายเป็นทางเดียวที่จะได้คีย์ลงนามแบบ qualified มาเลย trust service provider แจก CSC endpoint กับ OAuth client มา ไม่ใช่ไฟล์ PFX หรือ USB token จึงไม่มีอะไรให้โหลดลง certificate store ท้องถิ่นอย่างที่การลงนามผ่าน Windows cert store ด้วย CNG กับ CAPI ทำ การ integrate แบบเดาสุ่มล้มในแบบที่ทายได้: call signHash timeout แล้วรอบ retry ก็ลงนามสัญญาเดียวกันซ้ำสองครั้ง หรือชุด invoice สี่สิบใบจุด one-time password สี่สิบครั้งเพราะแต่ละ hash ถูก authorize แยกกัน สิ่งที่ provider ทำส่วนใหญ่คือการเฝ้ากันสองความล้มเหลวนี้

ทำไม HotPDF จึงยกเรื่อง HTTP ให้แอปพลิเคชันของคุณจัดการ

เพราะ transport คือจุดพอดีที่ทุกการ deploy ต่างกัน proxy, TLS pinning, client certificate, OAuth vault ขององค์กรและนโยบาย logging ล้วนอาศัยอยู่ในชั้น HTTP THPDFCSCSignatureProvider จึงคุมสถานะของโปรโตคอลแล้วเรียกฟังก์ชัน THPDFCSCTransport หนึ่งครั้งต่อ request provider ยื่น THPDFCSCTransportRequest ให้คุณพร้อม Method (เป็น POST เสมอ), URL เต็มที่ประกอบจาก ServiceBaseURL บวก path ของ endpoint, header Authorization แบบ bearer ที่พร้อมใช้, ContentType, Body แบบ JSON, IdempotencyKey, เลข Attempt และ MaxResponseBytes คุณเติม THPDFCSCTransportResponse ด้วย StatusCode, Body กับ RetryAfterMS แล้วคืนหนึ่งค่าจาก ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure หรือ ctsCancelled

แผนภาพขอบเขต transport CSC ใน HotPDF: THPDFCSCSignatureProvider คุมโปรโตคอลแล้วยื่น THPDFCSCTransportRequest ให้โค้ดของคุณพร้อม method POST, URL เต็ม, header Authorization แบบ bearer ที่พร้อมใช้, body JSON, IdempotencyKey และเลข attempt คุณคืน StatusCode, Body, RetryAfterMS บวกหนึ่งจากสี่ค่าสถานะ cts ขณะที่คีย์ไม่เคยหลุดจาก HSM
provider จำแนก status code ด้วยตัวเอง transport ที่แปล 503 ที่มีคำตอบเป็นความล้มเหลวถาวรจึงปิด retry logic ไปเงียบ ๆ ขณะที่ proxy กับนโยบาย 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  // ชื่อ header ตามที่บริการของคุณระบุในเอกสาร
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // ปัญหา socket หรือ DNS: retry ได้
        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;

กฎเดียวที่ควรท่องจำ: คืน ctsSuccess ทุกครั้งที่ server ตอบจริง แม้จะเป็น 503 provider จำแนก status code เอง transport ที่แปล 429 เป็น ctsPermanentFailure จึงปิด retry logic ที่จะเล่าต่อไปนี้ไปเงียบ ๆ ส่วน constructor เข้มงวดในทิศกลับกัน — โยน EHPDFCSCSignatureProviderError เมื่อ transport หาย, CredentialID ว่าง, ไม่มีทั้ง AccessToken และ callback ของ token, budget เกินช่วง หรือ ServiceBaseURL ไม่ใช่ HTTPS http:// ธรรมดายอมรับได้เฉพาะเมื่อเปิด AllowInsecureHTTP ซึ่งอยู่ได้แค่ใน test rig เท่านั้น

SAD คืออะไร และทำไม HotPDF ถึงทิ้งมันหลังใช้ครั้งเดียว

THPDFCSCSignatureProvider ถือว่า Signature Activation Data (SAD) ใช้ได้ครั้งเดียว: มันถูกล้างออกจากสถานะของ provider ทันทีที่ signatures/signHash ถูกยอมรับ แม้ตัวลายเซ็นจะมาถึงทีหลังผ่าน polling แบบ async ก็ตาม SAD คือหลักฐานของบริการว่าผู้ลงนามรับรอง hash ชุดนี้พอดี และ SAD ที่ค้างอยู่ในหน่วยความจำคือ authorization ที่รอถูกใช้กับเอกสารผิด ๆ

ด้วยค่า default จาก THPDFCSCOptions.Default — RequireSAD กับ AutoAuthorize เป็น True ทั้งคู่ — provider โหลด credentials/info ครั้งเดียว ถาม THPDFCSCAuthenticationCallback ของคุณหาค่า authData (OTP, PIN หรืออะไรก็ตามที่บล็อก auth ของ credential ทวง) แล้วโพสต์ credentials/authorize 200 แบก SAD มาให้ตรง ๆ, 202 แบก handle ที่ถูก poll ผ่าน credentials/authorizeCheck ได้ถึง MaxPollAttempts (60) ครั้งทุก PollIntervalMS (250 ms) callback คืนได้ไม่เกิน 32 ค่า แต่ละค่ามี ID ไม่ว่างไม่เกิน 256 ไบต์และค่าไม่เกิน 4,096 ไบต์ ถ้าเครือข่ายหลุดก่อน signHash จะถูกยอมรับ SAD ที่ได้มาอัตโนมัติจะถูกเก็บไว้ ชุดเดิมจึง retry ได้โดยไม่ต้องไปถามผู้ลงนามซ้ำ

แผนภาพวงจรชีวิต SAD ใน HotPDF: เมื่อเปิด RequireSAD กับ AutoAuthorize provider โหลด credentials/info ครั้งเดียว ถาม authentication callback หาค่า OTP หรือ PIN โพสต์ credentials/authorize poll credentials/authorizeCheck ได้ถึง 60 ครั้งทุก 250 ms เมื่อคำตอบเป็น 202 และล้าง Signature Activation Data ทันทีที่ signatures/signHash ถูกยอมรับ โดยเก็บ SAD ที่ได้มาไว้กรณีเครือข่ายหลุดก่อนการยอมรับ
SAD ที่ค้างอยู่ในหน่วยความจำคือ authorization ที่รอถูกใช้กับเอกสารผิด ๆ และ SAD ที่ส่งผ่าน options มาล่วงหน้าถูกใช้กับ request แบบ hash เดียวเท่านั้น
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, โหมด async, 2 retries
  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 ของคุณ; ForceRefresh เป็น True หลังบริการตอบ 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 ของคุณ
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

SAD ที่คุณส่งเข้าไปเองผ่าน Options.SAD ทำงานต่างออกไปและก็ตั้งใจให้เป็นแบบนั้น HotPDF รู้ไม่ได้ว่ามันถูกออกให้กับ hash ชุดไหน provider จึงใช้ SAD ที่ตั้งไว้ล่วงหน้ากับ request แบบ hash เดียวเท่านั้น สำหรับชุดที่ปิด AutoAuthorize ไว้ provider จะพังด้วยข้อความ "CSC SAD is not pinned to the requested hash batch" แทนที่จะเดา

SignHashBatch ลงนามเอกสารหลายฉบับด้วย authorization เดียวอย่างไร

SignHashBatch ส่ง credentials/authorize หนึ่งครั้งและ signatures/signHash หนึ่งครั้งสำหรับ digest ได้ถึง MaxBatchSignatures (64) ตัว และประกอบ body ทั้งสองจาก array ชุดเดียวกัน เพื่อให้ numSignatures, ลำดับของ hashes กับ hashAlgorithmOID เหมือนกันเป๊ะในสอง call ความเหมือนนั้นแหละคือสิ่งที่โมเดล multisign ของ CSC ทวง วน method Sign แบบ hash เดียวสี่สิบรอบคือได้ authorization สี่สิบชุด ส่ง authorize กับ signHash ที่กันโลกกันเองแล้วบริการอาจเผา SAD กับชุดที่ผิด

ก่อนมีการจราจรผ่านเครือข่ายแม้แต่แพ็กเกตเดียว provider validate ชุดก่อน request ทุกตัวต้องเป็น digest (sikDigest) ขนาด 1 ถึง 1,024 ไบต์พร้อม digest OID และ request ทุกตัวต้องใช้ signature algorithm OID ตัวเดียวกัน, digest OID ตัวเดียวกันและ สำหรับ RSASSA-PSS, ความยาว salt เดียวกัน ชุดแบบหลาย hash ยังโหลด credentials/info และคืน spsUnsupported เมื่อค่า multisign ของ credential น้อยกว่าขนาดชุด จากนั้น SAD ถูกตรึงกับ fingerprint ของชุด — SHA-256 ครอบ label เวอร์ชัน, จำนวน และต่อหนึ่ง request คือ algorithm OID, digest OID, algorithm, ความยาว salt กับไบต์ digest แต่ละตัวมี prefix ความยาว สลับ hash สองตัวก็กลายเป็นชุดต่างที่ต้องขอ authorization ใหม่

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;           // signAlgo ถูก derive เมื่อ AlgorithmOID ว่าง
    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]; จำนวนถูกเช็กเทียบกับ request แล้ว
end;

สำหรับ RSASSA-PSS provider ส่ง signAlgoParams เพิ่มด้วย ซึ่งเป็นโครงสร้าง DER RSASSA-PSS-params แบบ base64 ที่มี hash algorithm, MGF1 และความยาว salt การประกอบมันคือการ encode OID และ v2.748.5 แก้มุมหนึ่งของเรื่องนี้: X.690 §8.19.4 พับ arc สองตัวแรกเป็นค่าเดียว (40 × ตัวแรก + ตัวที่สอง) และใต้ราก 2 arc ที่สองที่เกิน 39 จะดันค่านั้นพ้น 127 ซึ่งต้องใช้รูปหลายไบต์แบบ base-128 ที่บิลด์ก่อนหน้าไม่ได้ใช้ OID SHA-2 ไม่มีตัวไหนโดน — 2.16 พับเหลือ 96 — แต่ OID ที่ผิดรูปตอนนี้โยน error ของ provider เองแทนที่จะเป็น EConvertError

ทำไม request ที่ retry ไม่ผลิตลายเซ็นที่สอง

THPDFCSCSignatureProvider บังคับให้ call ที่ retry ได้ทุกตัวแบก idempotency key แบบ deterministic และแคชผลที่เสร็จแล้วไว้ retry หลัง response หายจึงได้ลายเซ็นชุดเดิมกลับมา แทนที่จะไปขอใหม่จาก HSM key คือ csc- ต่อด้วย SHA-256 แบบ hex ของ operation identifier บวกเฟส และเฟสฝัง fingerprint ของชุดไว้ทั้งกับ authorization และ signHash เลือก hash แทนการตัดทอนมีความหมาย: operation ID ยาวสองตัวที่ใช้ prefix ร่วมกันจะชนกันใต้การตัดทอน ขณะที่คีย์แบบ fixed-length ที่อ้างตามเนื้อหายังไม่ซ้ำและนิ่งข้ามทุก attempt

นโยบาย retry ในเส้นทาง request ที่ใช้ร่วมกันแคบโดยตั้งใจ:

  • HTTP 401 บังคับ refresh token หนึ่งครั้งเป๊ะผ่าน callback ของ access token แล้ว request ถูกรันซ้ำหนึ่งครั้งเมื่อ callback ของ access token ถูกกำหนดไว้ 401 ครั้งที่สองคือจุดจบ
  • 4xx อื่น ๆ กับ ctsPermanentFailure จบ call ด้วย spsProviderError และ error_description ของบริการตกลงใน LastError
  • 408, 429, 5xx และ ctsTemporaryFailure ถูก retry ได้ถึง RetryLimit (default 2) รอ Retry-After หรือ RetryBaseDelayMS × 2attempt (ฐาน 100 ms) เพดานที่ MaxRetryAfterMS (5,000 ms)
  • การรอวิ่งเป็นชิ้นละ 25 ms ที่เช็ก Cancel ผู้ใช้ที่ยกเลิกจึงไม่ต้องนั่งกิน back-off ห้าวินาที
  • signHash ถูก retry เมื่อ EnableIdempotency เปิดเท่านั้น ปิดมันแล้ว timeout หลังส่งไปคือจุดจบ เพราะไม่มีใครรู้ได้ว่าคีย์ถูกใช้ไปหรือยัง
แผนภาพนโยบาย retry ใน HotPDF: call ที่ retry ได้ทุกตัวแบก idempotency key csc- แบบ deterministic ที่ hash จาก operation identifier กับเฟส, HTTP 401 บังคับ refresh token หนึ่งครั้งเป๊ะ, 4xx อื่น ๆ จบด้วย spsProviderError และ 408, 429, 5xx หรือความล้มเหลวชั่วคราวของ transport ถูก retry ได้ถึง RetryLimit ที่ 2 ระหว่างรอ Retry-After หรือ backoff แบบเอกซ์โพเนนเชียลเพดาน 5,000 ms
ชุดที่เสร็จแล้วถูกแคชตาม operation identifier, credential และ fingerprint และในโหมด async responseID ที่เก็บไว้ทำให้ call ซ้ำเดิน polling ต่อแทนที่จะส่ง hash ใหม่

การลงนามแบบ async (operationMode "A" ซึ่งเป็น default) เพิ่มการเฝ้าอีกชั้น: responseID ถูกเก็บก่อน polling signatures/signPolling call ซ้ำด้วย operation identifier เดิมจึงเดิน polling ต่อแทนที่จะส่งใหม่ ชุดที่เสร็จแล้วนั่งอยู่ในแคชที่อ้างด้วย operation identifier, credential และ fingerprint เพดาน MaxOperationCacheEntries (128) และถูกคืนเป็นสำเนาลึก แคชนี้อาศัยอยู่ใน instance ของ provider และไม่รอดจากการรีสตาร์ต ส่วน idempotency key รอด เพราะมันถูก derive มา ไม่ใช่สุ่ม โปรเซสที่รีสตาร์ตแล้วใช้ operation identifier เดิมจึงส่ง key เดิม — บริการจะ deduplicate ตามมันไหมเป็นคำสัญญาของบริการ ไม่ใช่ของ HotPDF

จะฝังลายเซ็น CSC ลงใน PDF อย่างไร

ส่ง provider ให้ HPDFCMSSignPDFStreamWithProvider พร้อม certificate ปลายทางจาก GetCertificateChain HotPDF สร้าง CMS SignedData แล้ว provider ลงนาม digest ของ signed attributes ไฟล์ PDF ต้นทางต้องมี placeholder /ByteRange กับ /Contents ที่ THPDFPage.AddSignedSignatureField เขียนไว้ ตรงตามในเวิร์กโฟลว์ลงนาม PAdES ใน HotPDF และโมเดล provider ก็เป็นแบบเดียวกับที่ครอบไว้ในsignature provider แบบเสียบได้ของ HotPDF สำหรับ ML-DSA กับ EdDSA

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC ลิสต์ certificate ปลายทางไว้ก่อน
  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;

การกำหนดขนาด placeholder /Contents ผ่าน EstimateSignatureSize ซึ่งคืน EstimatedSignatureBytes เมื่อคุณตั้งไว้และไม่งั้นก็คืนขนาด modulus ของ RSA จากความยาวคีย์ของ credential สำหรับ ECDSA ตั้ง EstimatedSignatureBytes เอง ไม่งั้นการประเมินรายงาน spsUnsupported เวอร์ชันลงนามแบบปรับขนาดอัตโนมัติจะลงนามใหม่เมื่อพบว่า placeholder เล็กเกินไป และมันทำแบบนั้นเฉพาะกับ provider ที่ประกาศ spcSafeSignRetry — ซึ่ง THPDFCSCSignatureProvider ประกาศเมื่อ EnableIdempotency เปิดเท่านั้น สำหรับเวิร์กโฟลว์ PAdES-B-T TimestampDigest ขอ timestamp token จากบริการเดียวกันผ่าน signatures/timestamp เพดาน MaxTimestampBytes (1 MB)

อะไรบ้างที่ CSC provider ไม่ทำ

มันไม่ลงนามข้อความ ลงนามแต่ digest Ed25519 กับ Ed448 ในโหมด pure ยื่นข้อความ signed attributes ทั้งก้อน (sikMessage) ให้ provider และตัว validate ชุดปฏิเสธมันในฐานะ input ผิดรูป เพราะ signHash ตามนิยามคือของที่ยึด hash unit ของ provider compile ภายใต้ Free Pascal ด้วยชนิดฟังก์ชันธรรมดาแทน anonymous method แต่ตัวสร้าง CMS ที่ขับด้วย provider ยังโยน exception บน FPC ณ ตอนนี้ การฝังลายเซ็น CSC ลงใน PDF จึงเป็นเส้นทางของ Delphi

มันยังไม่ตัดสินนโยบาย CredentialInfo รายงานสถานะคีย์, สถานะ certificate, โหมด authorization, ระดับ SCAL และเพดาน multisign แต่ provider ไม่เคยปฏิเสธคีย์ที่ถูกปิดหรือ credential SCAL1 ด้วยตัวเอง — เช็กสองอย่างนี้ก่อนโชว์ prompt OTP ให้ผู้ลงนาม และ instance ของ provider หนึ่งตัวลงนามหนึ่งชุดต่อครั้ง SignHashBatch ถูก serialize ภายในสอง thread จึงแย่ง SAD เดียวกันไม่ได้ ซึ่งแปลว่า throughput มาจากการชงเป็นชุด ไม่ใช่จากการแชร์ provider ข้าม worker thread ลายเซ็นที่ได้จะ qualified หรือไม่ขึ้นกับ trust service กับ credential ของมัน ไม่ใช่ library ที่พา hash ไปถึงตรงนั้น

provider CSC, ตัวสร้าง CMS กับ PAdES และ provider แบบ local กับ PKCS#11 ship มาทั้งหมดในHotPDF Delphi PDF component