HotPDF подписывает PDF-документы приватным ключом, который держит удалённый сервис Cloud Signature Consortium (CSC), через THPDFCSCSignatureProvider — провайдер подписи, ведущий CSC API (credential info, авторизация, signatures/signHash и поллинг), пока ваше Delphi-приложение поставляет HTTP-транспорт и OAuth access token. Ключ никогда не покидает HSM сервиса
И это всё чаще единственный способ вообще получить квалифицированный ключ подписи. Доверенные сервисы выдают CSC-эндпоинт и OAuth-клиент, а не PFX-файл или USB-токен, так что загружать в локальное хранилище сертификатов, как это делает подпись через cert store Windows на CNG и CAPI, нечего. Наивная интеграция падает предсказуемо: вызов signHash вышел в таймаут, и ретрай подписывает тот же контракт второй раз, или партия из сорока счетов триггерит сорок одноразовых паролей, потому что каждый хеш авторизовался отдельно. Большая часть работы провайдера — защита от этих двух сбоев
Почему HotPDF оставляет HTTP вашему приложению?
Потому что транспорт — ровно то место, где каждое развёртывание отличается. Прокси, TLS pinning, клиентские сертификаты, корпоративные хранилища OAuth и политика логирования живут в HTTP-слое, поэтому THPDFCSCSignatureProvider дирижирует состоянием протокола, а на каждый запрос вызывает функцию THPDFCSCTransport. Провайдер вручает вам THPDFCSCTransportRequest с Method (всегда POST), полным URL, собранным из ServiceBaseURL плюс пути эндпоинта, готовым bearer-заголовком Authorization, 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, ни колбэк токена, бюджет вне диапазона или 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 кредитеншала) и шлёт credentials/authorize. Ответ 200 несёт SAD напрямую; 202 несёт хендл, который поллится через credentials/authorizeCheck до MaxPollAttempts (60) раз с интервалом PollIntervalMS (250 мс). Колбэк может вернуть максимум 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 кредитеншала меньше партии. Затем 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 key и кэширует завершённые результаты, так что ретрай после потерянного ответа возвращает исходные подписи вместо запроса новых у HSM. Ключ — это csc- плюс hex SHA-256 от идентификатора операции и фазы, а фаза вшивает отпечаток партии и для авторизации, и для signHash. Хешировать, а не обрезать, важно: два длинных ID операций с общим префиксом столкнулись бы при обрезании, тогда как ключ фиксированной длины, адресуемый содержимым, остаётся уникальным и стабильным между попытками
Политика ретраев в общем пути запроса узка намеренно:
- HTTP 401 принуждает ровно один refresh токена через колбэк access-token, затем запрос повторяется один раз, если колбэк access-token назначен; второй 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, так что повторный вызов с тем же идентификатором операции продолжит поллинг вместо повторной отправки. Завершённые партии лежат в кэше с ключом из идентификатора операции, кредитеншала и отпечатка, с потолком MaxOperationCacheEntries (128), и возвращаются глубокими копиями. Этот кэш живёт в экземпляре провайдера и не переживает рестарт. Idempotency key переживает, потому что выводится, а не случаен, так что перезапущенный процесс, переиспользующий свой идентификатор операции, шлёт тот же ключ — дедуплицирует ли по нему сервис, это обещание сервиса, а не HotPDF
Как вложить CSC-подпись в PDF?
Передайте провайдера в HPDFCMSSignPDFStreamWithProvider вместе с сертификатом конечного владельца из GetCertificateChain; HotPDF строит CMS SignedData, а провайдер подписывает дайджест подписываемых атрибутов. Входному PDF нужны плейсхолдеры /ByteRange и /Contents, которые пишет THPDFPage.AddSignedSignatureField, — ровно как в воркфлоу подписи PAdES в HotPDF, а модель провайдера — та же, что разобрана в подключаемых провайдерах подписи 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-модуля из длины ключа кредитеншала. Для ECDSA задайте EstimatedSignatureBytes сами, иначе оценка сообщит spsUnsupported. Вариант подписи с автоподбором размера переподписывает, когда плейсхолдер оказался мал, и делает это только для провайдеров, анонсирующих spcSafeSignRetry, — THPDFCSCSignatureProvider анонсирует его лишь при включённом EnableIdempotency. Для воркфлоу PAdES-B-T TimestampDigest запрашивает метку времени у того же сервиса через signatures/timestamp с потолком MaxTimestampBytes (1 MB)
Чего провайдер CSC не делает?
Он не подписывает сообщения, только дайджесты. Ed25519 и Ed448 в чистом режиме вручают провайдеру всё сообщение подписываемых атрибутов (sikMessage), и валидатор партии отвергает это как кривое, ведь signHash по определению хеш-ориентирован. Юнит провайдера собирается под Free Pascal с обычными типами функций вместо анонимных методов, но CMS-билдеры, ведомые провайдером, под FPC сегодня поднимают исключения, так что вложить CSC-подпись в PDF — путь Delphi
Он и политику не решает. CredentialInfo сообщает статус ключа, статус сертификата, режим авторизации, уровень SCAL и лимит multisign, но провайдер сам не откажет отключённому ключу или кредитеншалу SCAL1 — проверяйте это, прежде чем показывать подписанту OTP-промпт. И один экземпляр провайдера подписывает одну партию за раз: SignHashBatch сериализован внутри, так что два потока не сгонятся за одной SAD, а значит, пропускная способность растёт от батчинга, а не от разделения провайдера между воркер-потоками. Квалифицированна ли итоговая подпись, решают доверенный сервис и его кредитеншал, а не библиотека, донёсшая туда хеш
Провайдер CSC, CMS- и PAdES-билдеры и локальный с PKCS#11 провайдеры выходят в HotPDF Delphi PDF component