HotPDF подписва PDF документи с частен ключ, държан от отдалечена услуга на Cloud Signature Consortium (CSC), чрез THPDFCSCSignatureProvider — signature provider, който управлява CSC API-то: credential info, authorization, signatures/signHash и pollване, докато вашето Delphi приложение доставя HTTP transport-а и OAuth access token-а. Ключът никога не напуска HSM-а на услугата
Това все повече е единственият начин изобщо да получите qualified подписващ ключ. Trust service provider-ите раздават CSC endpoint и OAuth клиент, не PFX файл или USB token, така че няма какво да се зареди в локален certificate store, както прави подписването от Windows cert store през CNG и CAPI. Наивната интеграция се проваля предвидимо: извикване на signHash отива на timeout и повторението подписва същия договор два пъти, или партида от четиридесет фактури палит четиридесет еднократни пароли, защото всеки хеш е бил оторизиран поотделно. Повечето от това, което provider-ът върши, е защита срещу тези два провала
Защо HotPDF оставя HTTP на вашето приложение?
Защото transport-ът е точно мястото, където всяко разгръщане се различава. Proxy-та, TLS pinning, client сертификати, корпоративни OAuth хранилища и logging политика живеят в HTTP слоя, затова THPDFCSCSignatureProvider оркестрира протоколното състояние и вика функция THPDFCSCTransport за всяка заявка. Provider-ът ви подава THPDFCSCTransportRequest с Method (винаги POST), пълния URL, изграден от ServiceBaseURL плюс пътя на endpoint-а, готов Authorization bearer header, ContentType, JSON Body, 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: подлежи на повторение
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, щом сървърът реално е отговорил, дори с 503. Provider-ът сам класифицира status кодовете, а transport, който превръща 429 в ctsPermanentFailure, тихо изключва retry логиката, описана по-долу. Конструкторът е строг в другата посока — вдига EHPDFCSCSignatureProviderError, когато transport липсва, CredentialID е празен, нито AccessToken, нито token callback е подаден, budget е извън диапазона или ServiceBaseURL не е HTTPS. Гол http:// се приема само с AllowInsecureHTTP, което принадлежи на тестов стенд и никъде другаде
Какво е SAD-ът и защо HotPDF го изхвърля след една употреба?
THPDFCSCSignatureProvider третира Signature Activation Data (SAD) като еднократна: изчиства се от състоянието на provider-а в момента, в който signatures/signHash е прието, дори самият подпис да пристига по-късно чрез асинхронно pollване. SAD-ът е доказателството на услугата, че подписващият е одобрил точно тези хешове, а SAD, седяща в паметта, е оторизация, чакаща да бъде изразходвана за грешния документ
С подразбиранятията от 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 се пази, така че същата партида може да се повтори, без да се пита подписващият пак
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, async режим, 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
// вашият OAuth клиент; 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 не може да знае за кои хешове е издадена, така че provider-ът ползва предварително зададена SAD само за заявка с един хеш. За партида с изключено AutoAuthorize provider-ът се проваля с „CSC SAD is not pinned to the requested hash batch", вместо да гадае
Как SignHashBatch подписва много документи с една оторизация?
SignHashBatch праща едно credentials/authorize и едно signatures/signHash за до MaxBatchSignatures (64) дайджеста, и строи двете тела от един и същ масив, така че numSignatures, редът на hashes и hashAlgorithmOID са идентични в двете извиквания. Това съвпадение е изисквано от CSC multisign модела. Пуснете ли single-hash метода Sign четиридесет пъти в цикъл, получавате четиридесет оторизации; пратете authorize и signHash, които не съвпадат, и услугата може да изразходва SAD-а срещу грешната партида
Преди какъвто и да е мрежов трафик provider-ът валидира партидата. Всяка заявка трябва да е дайджест (sikDigest) от 1 до 1 024 байта с digest OID, а всички заявки трябва да споделят един signature algorithm OID, един digest OID и, за RSASSA-PSS, една дължина на salt. Партида с много хешове също зарежда credentials/info и връща spsUnsupported, когато стойността multisign на credential-а е по-малка от партидата. После SAD-ът се закова към fingerprint на партидата — SHA-256 над етикет на версия, броя и, на заявка, algorithm OID, digest OID, алгоритъм, дължина на salt и digest байтове, всеки с префикс дължина. Разменете ли два хеша, това е друга партида, която иска свежа оторизация
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 се извлича, когато 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]; броят е проверен спрямо заявката
end;
За RSASSA-PSS provider-ът праща и signAlgoParams — base64 DER структура RSASSA-PSS-params с hash алгоритъма, MGF1 и дължината на salt. Строенето ѝ значи кодиране на OID-и, а v2.748.5 оправи ъгъл на това: X.690 §8.19.4 сгъва първите две арки в една стойност (40 × първа + втора), а под корена 2 втора арка над 39 избутва тази стойност над 127, където ѝ трябва base-128 многобайтовата форма, която по-старите build-ове не прилагаха. Никой SHA-2 OID не е засегнат — 2.16 се сгъва на 96 — но зле оформен OID вече вдига собствена грешка на provider-а вместо EConvertError
Защо повторена заявка не ражда втори подпис?
THPDFCSCSignatureProvider кара всяко повторяемо извикване да носи детерминиран idempotency ключ и кешира свършените резултати, така че повторение след изгубен отговор връща оригиналните подписи, вместо да пита HSM-а за нови. Ключът е csc-, следван от hex SHA-256 на идентификатора на операцията и фазата, а фазата вгражда fingerprint-а на партидата и за authorization, и за signHash. Хеширането вместо съкращаването има значение: два дълги operation ID, споделящи префикс, биха се сблъскали при съкращаване, докато ключ с фиксирана дължина, адресиран по съдържание, остава уникален и стабилен между опитите
Retry политиката в споделения път за заявки е тясна нарочно:
- HTTP 401 налага точно едно опресняване на token-а през access-token callback-а, после заявката се повтаря веднъж, ако е закачен access-token callback; второ 401 е финално
- Останалите 4xx отговори и
ctsPermanentFailureприключват извикването сspsProviderError, аerror_descriptionна услугата отива вLastError - 408, 429, 5xx и
ctsTemporaryFailureсе повтарят доRetryLimit(по подразбиране 2), чакайкиRetry-AfterилиRetryBaseDelayMS× 2attempt (база 100 ms), капирано наMaxRetryAfterMS(5 000 ms) - Чаканията вървят на резки от 25 ms, които проверяват
Cancel, така че потребител, прекъснал операцията, не седели през петсекунден back-off signHashсе повтаря само докатоEnableIdempotencyе включена; изключете ли я, timeout след подаване е финален, защото никой не може да каже дали ключът вече е ползван
Асинхронното подписване (operationMode „A", по подразбиране) добавя още една защита: responseID се записва, преди да се pollва signatures/signPolling, така че повторено извикване със същия operation идентификатор подновява pollването, вместо да подава наново. Свършените партиди седят в кеш с ключове operation идентификатор, credential и fingerprint, капиран от MaxOperationCacheEntries (128) и връщани като дълбоки копия. Този кеш живее в инстанцията на provider-а и не оцелява при рестарт. Idempotency ключът оцелява, защото е извлечен, а не случаен, така че рестартиран процес, преизползващ своя operation идентификатор, праща същия ключ — дали услугата дедупликира по него, е обещание на услугата, не на HotPDF
Как вмъквате CSC подпис в PDF?
Подайте provider-а на HPDFCMSSignPDFStreamWithProvider заедно с end-entity сертификата от GetCertificateChain; HotPDF строи CMS SignedData, а provider-ът подписва дайджеста на signed attributes. Входният PDF има нужда от /ByteRange и /Contents placeholder, които THPDFPage.AddSignedSignatureField записва — точно както в PAdES подписващия workflow в HotPDF — а provider моделът е същият, покрит в plug-ватите 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 изброява 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;
Оразмеряването на /Contents placeholder-а минава през EstimateSignatureSize, който връща EstimatedSignatureBytes, ако сте го задали, а иначе размера на RSA модула от дължината на ключа на credential-а. За ECDSA задайте EstimatedSignatureBytes сами, или оценката докладва spsUnsupported. Авто-размерната подписваща варианти преизпълнява подписа, щом placeholder-ът се окаже твърде малък, и го прави само за provider-и, обявяващи spcSafeSignRetry — което THPDFCSCSignatureProvider прави само докато EnableIdempotency е включена. За PAdES-B-T workflows TimestampDigest иска timestamp token от същата услуга през signatures/timestamp, капиран на MaxTimestampBytes (1 MB)
Какво CSC provider-ът не върши?
Не подписва съобщения, само дайджести. Ed25519 и Ed448 в pure mode подават на provider-а цялото съобщение със signed attributes (sikMessage), а batch валидаторът го отхвърля като зле оформено, защото signHash по дефиниция е базиран на хеш. Provider unit-ът се компилира под Free Pascal с обикновени функционални типове вместо anonymous методи, но CMS builder-ите, водени от provider, днес вдигат грешка под FPC, така че вграждането на CSC подпис в PDF е Delphi път
Не решава и политика. CredentialInfo докладва статуса на ключа, статуса на сертификата, authorization режима, SCAL нивото и лимита multisign, но provider-ът сам няма да откаже изключен ключ или SCAL1 credential — проверете тези неща, преди да покажете на подписващия OTP prompt. И една инстанция на provider подписва една партида в даден момент: SignHashBatch е сериализиран вътрешно, така че две нишки не могат да се борят за една SAD, което значи, че throughput идва от партидиране, не от споделяне на provider между worker нишки. Дали полученият подпис е qualified, зависи от trust услугата и нейния credential, не от библиотеката, която е занесла хеша дотам
CSC provider-ът, CMS и PAdES builder-ите и локалните и PKCS#11 provider-ите излизат всичките в HotPDF Delphi PDF компонента