HotPDF ký tài liệu PDF bằng một khóa riêng do một dịch vụ Cloud Signature Consortium (CSC) từ xa nắm giữ, thông qua THPDFCSCSignatureProvider, một signature provider điều khiển CSC API — credential info, authorization, signatures/signHash và polling — trong khi ứng dụng Delphi của bạn cung cấp HTTP transport và OAuth access token. Khóa không bao giờ rời HSM của dịch vụ
Đó ngày càng là cách duy nhất để có nổi một khóa ký qualified. Các nhà cung cấp dịch vụ tin cậy phát cho bạn một endpoint CSC và một OAuth client, chứ không phải một tệp PFX hay một USB token, nên chẳng có gì để nạp vào certificate store cục bộ như cách ký qua cert store Windows bằng CNG và CAPI đã làm. Bản tích hợp ngây thơ sẽ gục theo những cách đoán trước được: một lệnh gọi signHash hết giờ và lượt retry ký trùng hợp đồng hai lần, hay một lô bốn mươi hóa đơn làm nổ ra bốn mươi mật khẩu dùng một lần vì mỗi hash được authorize riêng. Phần lớn việc provider làm là phòng vệ trước đúng hai thất bại ấy
Vì sao HotPDF để phần HTTP cho ứng dụng của bạn?
Vì transport chính là chỗ mà mọi bản triển khai khác nhau. Proxy, TLS pinning, client certificate, kho OAuth của công ty và chính sách log đều sống ở tầng HTTP, nên THPDFCSCSignatureProvider điều phối trạng thái giao thức và gọi một hàm THPDFCSCTransport cho mỗi request. Provider đưa cho bạn một THPDFCSCTransportRequest với Method (luôn là POST), URL đầy đủ dựng từ ServiceBaseURL cộng đường endpoint, một header bearer Authorization sẵn sàng, ContentType, Body JSON, một IdempotencyKey, số Attempt và MaxResponseBytes. Bạn đổ vào một THPDFCSCTransportResponse các giá trị StatusCode, Body và RetryAfterMS, rồi trả về một trong ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure hay 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 // tên header theo đúng tài liệu dịch vụ của bạn ghi
Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
string(Request.IdempotencyKey))];
try
HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
except
on ENetHTTPClientException do
Exit(ctsTemporaryFailure); // rắc rối socket hay DNS: retry được
end;
Response.StatusCode := HttpResp.StatusCode; // báo 503 nguyên vẹn, đừng tự phân loại
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;
Một luật đáng thuộc lòng: trả về ctsSuccess bất cứ khi nào server thực sự có trả lời, kể cả 503. Provider tự phân loại status code, và một transport biến 429 thành ctsPermanentFailure sẽ âm thầm vô hiệu hóa logic retry nói dưới đây. Constructor nghiêm ngặt theo hướng ngược lại — nó ném EHPDFCSCSignatureProviderError khi thiếu transport, CredentialID rỗng, chẳng có AccessToken nào lẫn callback token nào được cung cấp, một budget vượt khoảng cho phép, hay ServiceBaseURL không phải HTTPS. http:// thuần chỉ được chấp nhận với AllowInsecureHTTP, thứ chỉ hợp với một rig kiểm thử chứ không nơi nào khác
SAD là gì, và vì sao HotPDF vứt nó sau một lần dùng?
THPDFCSCSignatureProvider coi Signature Activation Data (SAD) là dùng một lần: nó bị xóa khỏi trạng thái provider ngay khi signatures/signHash được chấp nhận, kể cả khi chữ ký thực sự đến sau qua polling bất đồng bộ. SAD là bằng chứng của dịch vụ rằng người ký đã duyệt chính những hash này, và một SAD còn vương trong bộ nhớ là một ủy quyền đang chờ bị tiêu vào nhầm tài liệu
Với các mặc định của THPDFCSCOptions.Default — cả RequireSAD lẫn AutoAuthorize đều True — provider nạp credentials/info một lần, hỏi THPDFCSCAuthenticationCallback của bạn các giá trị authData (một OTP, một PIN, bất cứ gì khối auth của credential đòi hỏi), rồi gửi credentials/authorize. Một 200 mang SAD thẳng; một 202 mang một handle được poll qua credentials/authorizeCheck tối đa MaxPollAttempts (60) lần mỗi PollIntervalMS (250 ms). Callback có thể trả về tối đa 32 giá trị, mỗi giá trị có một ID không rỗng tới 256 byte và một giá trị tới 4.096 byte. Nếu mạng đứt trước khi signHash được chấp nhận, một SAD thu được tự động được giữ lại để cùng lô có thể retry mà chẳng phải hỏi lại người ký
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, chế độ async, 2 lần 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 của bạn; ForceRefresh là True sau khi dịch vụ trả 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 của bạn
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
Một SAD bạn tự truyền vào qua Options.SAD hành xử khác đi, và là chủ đích. HotPDF không thể biết nó được cấp cho những hash nào, nên provider chỉ dùng một SAD cài sẵn cho request một hash. Với một lô mà AutoAuthorize bị tắt, provider thất bại với "CSC SAD is not pinned to the requested hash batch" thay vì đoán
SignHashBatch ký nhiều tài liệu với một ủy quyền thế nào?
SignHashBatch gửi một credentials/authorize và một signatures/signHash cho tới MaxBatchSignatures (64) digest, và dựng cả hai body từ cùng một mảng để numSignatures, thứ tự hashes và hashAlgorithmOID giống hệt nhau trong hai lệnh gọi. Sự khớp đó chính là điều mô hình multisign CSC đòi hỏi. Lặp phương thức Sign một-hash bốn mươi lần là bạn có bốn mươi ủy quyền; gửi một authorize và một signHash không khớp nhau thì dịch vụ có thể tiêu SAD vào sai lô
Trước bất kỳ giao tiếp mạng nào, provider xác thực lô. Mỗi request phải là một digest (sikDigest) từ 1 tới 1.024 byte với một OID digest, và mọi request phải chia sẻ một OID thuật toán ký, một OID digest và, với RSASSA-PSS, một độ dài salt. Một lô nhiều hash còn nạp credentials/info và trả về spsUnsupported khi giá trị multisign của credential nhỏ hơn lô. SAD sau đó được ghim vào một fingerprint lô — một SHA-256 trên một nhãn phiên bản, số lượng và, cho từng request, OID thuật toán, OID digest, thuật toán, độ dài salt cùng byte digest, mỗi cái có tiền tố độ dài. Tráo hai hash cho nhau là bạn có một lô khác cần một ủy quyền mới
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
I: Integer;
begin
SetLength(Requests, Length(Digests)); // Digests: các giá trị SHA-256 bạn đã tính
for I := 0 to High(Digests) do
begin
Requests[I] := Default(THPDFSignatureProviderRequest);
Requests[I].Algorithm := hsaRSAPKCS1v15; // signAlgo suy ra khi AlgorithmOID rỗng
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] thuộc về Digests[I]; số lượng đã được đối chiếu với request
end;
Với RSASSA-PSS, provider còn gửi signAlgoParams, một cấu trúc DER RSASSA-PSS-params mã hóa base64 chứa thuật toán hash, MGF1 và độ dài salt. Dựng nó nghĩa là mã hóa OID, và bản 2.748.5 đã vá một góc khuất của việc đó: X.690 §8.19.4 gập hai arc đầu tiên thành một giá trị (40 × arc đầu + arc hai), và dưới gốc 2, một arc thứ hai trên 39 đẩy giá trị đó vượt 127, nơi nó cần dạng đa byte base-128 mà các bản build trước chưa áp. Chẳng OID SHA-2 nào bị ảnh hưởng — 2.16 gập thành 96 — nhưng một OID dị dạng giờ ném lỗi riêng của provider thay vì một EConvertError
Vì sao một request được retry không sinh ra chữ ký thứ hai?
THPDFCSCSignatureProvider buộc mọi lệnh gọi retry-được mang một idempotency key tất định và cache các kết quả đã xong, nên một lượt retry sau khi mất phản hồi trả về các chữ ký gốc thay vì xin HSM chữ ký mới. Key là csc- theo sau bằng hex SHA-256 của định danh thao tác và pha, và pha nhúng fingerprint lô cho cả authorization lẫn signHash. Hash thay vì cắt ngắn mới là chuyện đáng nói: hai operation ID dài dùng chung một tiền tố sẽ đụng nhau dưới phép cắt ngắn, còn một key đánh địa chỉ theo nội dung, độ dài cố định thì độc nhất và ổn định qua các lần thử
Chính sách retry trong đường request dùng chung được siết hẹp một cách chủ đích:
- HTTP 401 ép đúng một lần refresh token qua callback access-token, rồi request được lặp lại một lần khi có callback access-token; một 401 thứ hai là chung cuộc
- Các phản hồi 4xx khác và
ctsPermanentFailurechấm dứt lệnh gọi vớispsProviderError, vàerror_descriptioncủa dịch vụ rơi vàoLastError - 408, 429, 5xx và
ctsTemporaryFailuređược retry tối đaRetryLimitlần (mặc định 2), chờRetry-AfterhayRetryBaseDelayMS× 2attempt (nền 100 ms), trần ởMaxRetryAfterMS(5.000 ms) - Thời gian chờ chạy theo từng lát 25 ms có kiểm tra
Cancel, nên người dùng hủy không phải ngồi đợi một back-off năm giây signHashchỉ được retry khiEnableIdempotencybật; tắt nó đi thì một timeout sau khi gửi là chung cuộc, vì chẳng ai biết được khóa đã được dùng hay chưa
Ký bất đồng bộ (operationMode "A", mặc định) thêm một lớp phòng nữa: responseID được lưu trước khi poll signatures/signPolling, nên một lệnh gọi lặp lại với cùng định danh thao tác tiếp tục polling thay vì gửi lại. Các lô đã xong nằm trong một cache khóa theo định danh thao tác, credential và fingerprint, trần bởi MaxOperationCacheEntries (128) và được trả về dưới dạng bản copy sâu. Cache đó sống trong instance provider và không sống sót qua một lần khởi động lại. Idempotency key thì có, vì nó được suy ra chứ không ngẫu nhiên, nên một tiến trình khởi động lại mà tái dùng định danh thao tác sẽ gửi đúng key cũ — dịch vụ có loại bỏ trùng lặp theo nó hay không là lời hứa của dịch vụ, không phải của HotPDF
Đưa một chữ ký CSC vào một PDF thế nào?
Truyền provider cho HPDFCMSSignPDFStreamWithProvider cùng với certificate end-entity lấy từ GetCertificateChain; HotPDF dựng CMS SignedData và provider ký digest của các signed attribute. PDF đầu vào cần placeholder /ByteRange và /Contents mà THPDFPage.AddSignedSignatureField ghi ra, hệt như trong quy trình ký PAdES trong HotPDF, còn mô hình provider là mô hình đã được bàn trong các signature provider cắm được của HotPDF cho ML-DSA và EdDSA
var
Chain: THPDFCSCCertificateChain;
SignOpts: THPDFCMSSignOptions;
Src, Dst: TFileStream;
begin
if Provider.RefreshCredentialInfo <> spsValid then
raise Exception.Create(Provider.LastError);
Chain := Provider.GetCertificateChain; // CSC liệt kê certificate end-entity trước tiên
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;
Đo đạc placeholder /Contents đi qua EstimateSignatureSize, hàm trả về EstimatedSignatureBytes khi bạn đặt nó, còn không thì kích thước modulus RSA suy ra từ độ dài khóa của credential. Với ECDSA, hãy tự đặt EstimatedSignatureBytes, nếu không phép ước lượng báo spsUnsupported. Biến thể ký tự động cỡ lại sẽ ký lại khi một placeholder hóa ra quá nhỏ, và nó chỉ làm vậy với các provider quảng cáo spcSafeSignRetry — điều THPDFCSCSignatureProvider chỉ công nhận khi EnableIdempotency bật. Với quy trình PAdES-B-T, TimestampDigest xin một timestamp token từ chính dịch vụ đó qua signatures/timestamp, trần ở MaxTimestampBytes (1 MB)
CSC provider không làm gì?
Nó không ký thông điệp, chỉ ký digest. Ed25519 và Ed448 ở chế độ pure đưa cho provider cả thông điệp signed-attributes (sikMessage), và bộ kiểm tra lô từ chối nó như thể dị dạng, vì signHash theo định nghĩa là dựa trên hash. Unit provider compile được dưới Free Pascal với các kiểu hàm thường thay cho anonymous method, nhưng các bộ dựng CMS chạy theo provider thì ném lỗi trên FPC hiện nay, nên việc nhúng một chữ ký CSC vào PDF là đường của Delphi
Nó cũng không quyết định chính sách. CredentialInfo báo trạng thái khóa, trạng thái certificate, chế độ authorization, cấp SCAL và giới hạn multisign, nhưng provider sẽ không tự từ chối một khóa bị vô hiệu hay một credential SCAL1 — hãy kiểm tra những thứ đó trước khi đưa người ký thấy ô nhập OTP. Và một instance provider ký một lô tại một thời điểm: SignHashBatch được tuần tự hóa nội bộ nên hai thread không thể tranh nhau một SAD, nghĩa là throughput đến từ việc gom lô, chứ không phải từ việc chia sẻ provider giữa các worker thread. Chữ ký thu được có qualified hay không phụ thuộc vào dịch vụ tin cậy và credential của nó, chứ không phải vào thư viện đã mang hash tới đó
CSC provider, các bộ dựng CMS và PAdES, cùng các provider cục bộ và PKCS#11 đều có mặt trong HotPDF Delphi PDF component