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
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 ได้โดยไม่ต้องไปถามผู้ลงนามซ้ำ
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 หลังส่งไปคือจุดจบ เพราะไม่มีใครรู้ได้ว่าคีย์ถูกใช้ไปหรือยัง
การลงนามแบบ 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