HotPDF підписує PDF-документи приватним ключем, який тримає віддалений сервіс Cloud Signature Consortium (CSC), через THPDFCSCSignatureProvider — signature provider, що веде CSC API: credential info, авторизацію, signatures/signHash і полінг, — тоді як ваш Delphi-застосунок постачає HTTP-транспорт і OAuth access token. Ключ ніколи не залишає HSM сервісу
Дедалі частіше це взагалі єдиний спосіб отримати кваліфікований ключ для підписання. Провайдери довірчих послуг видають CSC-ендпоінт і OAuth-клієнта, а не PFX-файл чи USB-токен, тож немає чого завантажувати в локальний cert store, як це робить підписання через Windows cert store з CNG і CAPI. Наївна інтеграція падає передбачуваними способами: виклик signHash таймаутиться, і ретрай підписує той самий контракт двічі, або пакет із сорока рахунків тригерить сорок одноразових паролів, бо кожен хеш авторизували окремо. Більша частина того, що робить провайдер, — це оборона проти цих двох збоїв
Чому HotPDF лишає HTTP вашому застосунку?
Бо транспорт — це рівно те місце, де кожне розгортання відрізняється. Проксі, TLS pinning, клієнтські сертифікати, корпоративні OAuth-сховища і політика логування живуть у HTTP-шарі, тож THPDFCSCSignatureProvider оркеструє стан протоколу і викликає функцію THPDFCSCTransport на кожен запит. Провайдер віддає вам THPDFCSCTransportRequest з Method (завжди POST), повним URL, збудованим із ServiceBaseURL плюс шлях ендпоінта, готовим заголовком Authorization bearer, 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 // назва заголовка, як її документує ваш сервіс
Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
string(Request.IdempotencyKey))];
try
HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
except
on ENetHTTPClientException do
Exit(ctsTemporaryFailure); // проблеми сокета чи 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. Провайдер сам класифікує коди стану, і транспорт, який перетворює 429 на ctsPermanentFailure, тихо вимикає описану нижче логіку ретраїв. Конструктор строгий у протилежний бік — він піднімає EHPDFCSCSignatureProviderError, коли транспорту немає, CredentialID порожній, не подано ні AccessToken, ні callback-а за токеном, бюджет поза межами або ServiceBaseURL не HTTPS. Голий http:// приймається лише з AllowInsecureHTTP, якому місце в тестовому стенді і ніде більше
Що таке SAD і чому HotPDF викидає його після одного використання?
THPDFCSCSignatureProvider трактує Signature Activation Data (SAD) як одноразову: вона стирається зі стану провайдера в мить, коли signatures/signHash прийнято, навіть якщо сам підпис приїжджає пізніше через асинхронний полінг. SAD — це доказ сервісу, що підписувач схвалив саме ці хеші, а SAD, що тиняється в пам'яті, — це авторизація, яка чекає, щоб її витратили не на той документ
З усталеними налаштуваннями з THPDFCSCOptions.Default — і RequireSAD, і AutoAuthorize дорівнюють True — провайдер один раз завантажує credentials/info, питає ваш THPDFCSCAuthenticationCallback про значення authData (OTP, PIN, що там вимагає блок auth credential-а) і постить credentials/authorize. 200 несе SAD напряму; 202 несе хендл, який поліниться через credentials/authorizeCheck до MaxPollAttempts (60) разів з інтервалом PollIntervalMS (250 мс). Callback може повернути щонайбільше 32 значення, кожне з непорожнім ID до 256 байтів і значенням до 4 096 байтів. Якщо мережа падає до того, як signHash прийнято, автоматично отримана SAD зберігається, щоб той самий пакет можна було повторити, не питаючи підписувача знову
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, асинхронний режим, 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 не може знати, для яких хешів її видали, тож провайдер використовує попередньо задану SAD лише для запиту з одним хешем. Для пакета з вимкненим AutoAuthorize провайдер падає з "CSC SAD is not pinned to the requested hash batch" замість вгадувати
Як SignHashBatch підписує багато документів однією авторизацією?
SignHashBatch надсилає один credentials/authorize і один signatures/signHash для щонайбільше MaxBatchSignatures (64) дайджестів і будує обидва тіла з того самого масиву, щоб numSignatures, порядок hashes і hashAlgorithmOID були ідентичні в обох викликах. Саме того збігу вимагає multisign-модель CSC. Прогнайте однохешевий метод Sign сорок разів — отримаєте сорок авторизацій; надішліть authorize і signHash, що розходяться, — і сервіс може витратити SAD не на той пакет
До будь-якого мережевого трафіку провайдер валідує пакет. Кожен запит мусить бути дайджестом (sikDigest) від 1 до 1 024 байтів із OID дайджесту, і всі запити мусять ділити один OID алгоритму підпису, один OID дайджесту і, для RSASSA-PSS, одну довжину солі. Багатохешевий пакет також завантажує credentials/info і повертає spsUnsupported, коли значення multisign credential-а менше за пакет. Далі SAD причіплюється до відбитка пакета — SHA-256 по мітці версії, кількості і, на кожен запит, OID алгоритму, OID дайджесту, алгоритмі, довжині солі та байтах дайджесту, кожне з префіксом довжини. Поміняйте два хеші місцями — і це вже інший пакет, якому потрібна свіжа авторизація
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 провайдер також надсилає signAlgoParams — base64 DER-структуру RSASSA-PSS-params з алгоритмом хешування, MGF1 і довжиною солі. Будувати її — значить кодувати OID-и, і версія 2.748.5 полагодила там один кут: X.690 §8.19.4 згортає перші дві дуги в одне значення (40 × перша + друга), і під коренем 2 друга дуга вище 39 виштовхує те значення за 127, де воно потребує base-128 багатобайтової форми, яку раніші збірки не застосовували. Жоден OID SHA-2 не постраждав — 2.16 згортається в 96, — але некоректний OID тепер піднімає власну помилку провайдера замість EConvertError
Чому повторений запит не дає другого підпису?
THPDFCSCSignatureProvider змушує кожен повторюваний виклик нести детермінований idempotency-ключ і кешує завершені результати, тож ретрай після загубленої відповіді повертає оригінальні підписи, а не питає HSM про нові. Ключ — це csc- плюс hex SHA-256 ідентифікатора операції та фази, а фаза вшиває відбиток пакета і для авторизації, і для signHash. Хешувати, а не обрізати — має значення: два довгі ID операцій зі спільним префіксом зіштовхнулися б при обрізанні, тоді як ключ фіксованої довжини, адресований вмістом, лишається унікальним і стабільним між спробами
Політика ретраїв у спільному шляху запиту свідомо вузька:
- HTTP 401 форсує рівно одне оновлення токена через access-token callback, потім запит повторюється один раз, якщо access-token callback призначено; другий 401 — фінальний
- Інші відповіді 4xx і
ctsPermanentFailureзакінчують виклик ізspsProviderError, аerror_descriptionсервісу приземляється вLastError - 408, 429, 5xx і
ctsTemporaryFailureповторюються доRetryLimit(усталено 2), з чеканням наRetry-AfterчиRetryBaseDelayMS× 2attempt (база 100 мс), зі стелеюMaxRetryAfterMS(5 000 мс) - Чекання йдуть скибками по 25 мс, що перевіряють
Cancel, тож користувач, який перериває, не сидить крізь п'ятисекундний back-off signHashповторюється лише поки ввімкненоEnableIdempotency; вимкніть — і таймаут після надсилання фінальний, бо ніхто не скаже, чи ключ уже використали
Асинхронне підписання (operationMode "A", усталене) додає ще одну заставу: responseID зберігається до полінгу signatures/signPolling, тож повторений виклик з тим самим ідентифікатором операції продовжує полінг замість перевисилання. Завершені пакети сидять у кеші з ключем за ідентифікатором операції, credential-ом і відбитком, зі стелею MaxOperationCacheEntries (128) і повертаються глибокими копіями. Той кеш живе в інстансі провайдера і не переживає рестарту. Idempotency-ключ переживає, бо він виводиться, а не випадковий, тож перезапущений процес, який перевикористовує свій ідентифікатор операції, надсилає той самий ключ — а чи дедуплікує на ньому сервіс, це обіцянка сервісу, а не HotPDF
Як укласти CSC-підпис у PDF?
Передайте провайдера в HPDFCMSSignPDFStreamWithProvider разом із кінцевим сертифікатом із GetCertificateChain; HotPDF будує CMS SignedData, а провайдер підписує дайджест signed attributes. Вхідному PDF потрібні плейсхолдери /ByteRange і /Contents, які пише THPDFPage.AddSignedSignatureField, — рівно як у PAdES-робочому процесі підписання в HotPDF, а модель провайдера — та сама, що розібрана в плагінних 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 перелічує кінцевий сертифікат першим
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 іде через EstimateSignatureSize, який повертає EstimatedSignatureBytes, якщо ви його задали, а інакше розмір модуля RSA з довжини ключа credential-а. Для ECDSA задайте EstimatedSignatureBytes самі, або оцінка звітує spsUnsupported. Автоматично-розмірний варіант підписання перепідписує, коли плейсхолдер виявився замалим, і робить це лише для провайдерів, що рекламують spcSafeSignRetry, — а THPDFCSCSignatureProvider рекламує його лише поки ввімкнено EnableIdempotency. Для робочих процесів PAdES-B-T TimestampDigest випрошує токен часу в того самого сервісу через signatures/timestamp, зі стелею MaxTimestampBytes (1 МБ)
Чого CSC-провайдер не робить?
Він не підписує повідомлення, лише дайджести. Ed25519 і Ed448 у pure-режимі віддають провайдеру ціле повідомлення signed attributes (sikMessage), і валідатор пакета відкидає це як некоректне, бо signHash за визначенням хешовий. Юніт провайдера компілюється під Free Pascal зі звичайними типами функцій замість анонімних методів, але CMS-білдери, керовані провайдером, сьогодні падають під FPC, тож укладання CSC-підписа в PDF — це Delphi-шлях
Він також не вирішує політику. CredentialInfo звітує стан ключа, стан сертифіката, режим авторизації, рівень SCAL і ліміт multisign, але провайдер сам не відмовить вимкненому ключу чи credential-у SCAL1 — перевіряйте це, перш ніж показувати підписувачу OTP-промпт. І один інстанс провайдера підписує один пакет за раз: SignHashBatch серіалізується всередині, щоб два потоки не ганялися за однією SAD, а отже, пропускна здатність приходить із батчингу, а не з розподілу провайдера між робочими потоками. Чи є підсумковий підпис кваліфікованим, залежить від довірчого сервісу та його credential-а, а не від бібліотеки, яка занесла туди хеш
CSC-провайдер, CMS- і PAdES-білдери та локальні і PKCS#11-провайдери всі виходять у HotPDF Delphi PDF component