Запрос signatures/signHash к сервису удалённого подписания может уйти по таймауту после того, как HSM уже выдал подпись, и клиент не может сказать, на какой стороне этой черты он приземлился. Ретрайте вслепую — и одно одобрение может дать две подписи на одном договоре. Откажитесь ретраить — и ночной батч умрёт на одном-единственном 503. Подпишите сорок счетов по одному хешу за раз — и подписант наберёт сорок одноразовых паролей
Любая интеграция Cloud Signature Consortium (CSC) обязана решить и то и другое. Провайдеры трастовых сервисов, выпускающие квалифицированные ключи, всё чаще выдают CSC API-эндпоинт и OAuth-клиент вместо PFX-файла или USB-токена, поэтому импортировать в локальное хранилище нечего, как это работает у подписания из хранилища сертификатов Windows через CNG и CAPI. Ключ остаётся в HSM провайдера, и каждая подпись — круговой рейс по HTTPS
Почему HotPDF оставляет HTTP вашему приложению?
HotPDF подписывает через удалённый CSC-сервис с THPDFCSCSignatureProvider — провайдером подписи, исполняющим протокол CSC v2 (credential info, авторизация, signatures/signHash, поллинг), пока ваш Delphi-код владеет HTTP-транспортом и OAuth-токеном. Прокси, TLS pinning, клиентские сертификаты, хранилища токенов и логирование запросов живут в HTTP-слое, и в каждом развёртывании они разные
На каждый запрос провайдер строит THPDFCSCTransportRequest и зовёт вашу функцию THPDFCSCTransport. Запись несёт полный URL, готовый bearer-заголовок Authorization, JSON Body, IdempotencyKey, OperationIdentifier и номер Attempt. Ваша функция заполняет THPDFCSCTransportResponse полями StatusCode, Body и RetryAfterMS, затем возвращает ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure или ctsCancelled
Транспорту на RTL-ном THTTPClient нужно около сорока строк. Тело уходит сырыми байтами, поэтому JSON никогда не проходит через конверсию кодовых страниц:
uses
System.SysUtils, System.Classes, 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, Reply: TMemoryStream;
Headers: TNetHeaders;
HttpResp: IHTTPResponse;
begin
Response := Default(THPDFCSCTransportResponse);
Body := TMemoryStream.Create;
Reply := TMemoryStream.Create;
try
if Request.Body <> '' then
Body.WriteBuffer(Request.Body[1], Length(Request.Body));
Body.Position := 0;
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, TLS или проблемы сокета: ретраится
end;
Response.StatusCode := HttpResp.StatusCode; // пропускайте 429 и 503 нетронутыми
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
// Retry-After в секундах; значение HTTP-date откатывается здесь в 0
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
Единственное правило, которое стоит запомнить: возвращайте ctsSuccess всякий раз, когда сервер ответил, даже 503. Провайдер сам классифицирует коды статусов, и транспорт, отображающий 429 в ctsPermanentFailure, выключает описанную ниже логику ретраев. Тела больше MaxResponseBytes (по умолчанию 2 МБ) отвергаются с spsMalformed, что бы транспорт ни вернул
Конструктор строг в другую сторону. Он поднимает EHPDFCSCSignatureProviderError, когда транспорт отсутствует, ServiceBaseURL или CredentialID пусты, не подан ни AccessToken, ни колбэк токена, бюджет вне диапазона или URL не HTTPS. Голый http:// проходит только с выставленным AllowInsecureHTTP, и этот флаг живёт в тестовом стенде, нацеленном на локальный mock-сервис
Что такое SAD и почему она одноразовая?
THPDFCSCSignatureProvider трактует Signature Activation Data (SAD) как одноразовую: она вычищается из состояния провайдера в момент принятия signatures/signHash, даже когда сама подпись приезжает позже через асинхронный поллинг. SAD — доказательство сервиса, что подписант одобрил именно эти хеши. Зависшая в памяти, это авторизация, ждущая, чтобы её потратили на не тот документ
С дефолтами из THPDFCSCOptions.Default (RequireSAD и AutoAuthorize оба True) провайдер один раз грузит credentials/info, спрашивает у вашего THPDFCSCAuthenticationCallback значения authData, которых требует блок auth кредитеншала, — обычно OTP или PIN — и шлёт credentials/authorize. Ответ 200 несёт SAD напрямую. 202 несёт хендл, который провайдер поллит через credentials/authorizeCheck до MaxPollAttempts (60) раз с шагом PollIntervalMS (250 мс), если только сервис не шлёт Retry-After. Колбэк может вернуть максимум 32 значения, каждое с непустым ID до 256 байтов и значением до 4096 байтов
Вычистка происходит после принятия. Если сеть упала до принятия signHash, автоматически полученная SAD остаётся на месте, всё ещё привязанная к той же партии, поэтому ретрай уходит без второго OTP-вопроса. Подписант одобрил один раз; провайдер тратит это одобрение один раз
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
// TokenVault — ваш собственный 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);
Колбэк токена срабатывает, когда закэшированный токен отсутствует или находится в TokenRefreshSkewSeconds (30) от сообщённого вами истечения; ExpiresAtUTC нулём значит, что токен переиспользуется, пока 401 не принудит к обновлению
SAD, которую вы передаёте сами через Options.SAD, ведёт себя иначе, и нарочно. Ничто не говорит провайдеру, какие хеши покрывает эта SAD, поэтому предустановленная SAD используется только для запроса с одним хешем. Для мультихешевой партии со включённым AutoAuthorize он отбрасывает предустановленное значение и авторизует партию; с выключенным AutoAuthorize — падает с «CSC SAD is not pinned to the requested hash batch» вместо догадки. Сервисы, авторизующие одним OAuth-scope и никогда не выдающие SAD, работают с RequireSAD в False, и тогда член SAD не включается в тело signHash
Подписание сорока счетов с одним OTP-вопросом
SignHashBatch шлёт один credentials/authorize и один signatures/signHash для максимум MaxBatchSignatures (64) дайджестов и строит оба тела из одного массива, поэтому numSignatures, порядок hashes и hashAlgorithmOID согласованы между двумя вызовами. Прогоните одноразовый метод Sign сорок раз — и получите сорок авторизаций. Отправьте authorize и signHash, которые расходятся, — и сервис может потратить SAD против чужой партии
Валидация идёт до всякого сетевого трафика. Каждый запрос обязан быть дайджестом (sikDigest) от 1 до 1024 байтов с OID дайджеста, и все запросы обязаны разделять один алгоритм подписи, один OID дайджеста и, для RSASSA-PSS, одну длину соли. Мультихешевая партия вдобавок грузит credentials/info и возвращает spsUnsupported, когда значение multisign кредитеншала меньше размера партии. Авторизация затем привязывается к чувствительному к порядку SHA-256-отпечатку списка запросов, поэтому перестановка двух хешей даёт другую партию, требующую свежего одобрения
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
OpId: AnsiString;
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;
// Выводите ID из собственной записи прогона, никогда из свежего GUID на вызов
OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
Status := Provider.SignHashBatch(Requests, OpId, Signatures);
if Status = spsProviderError then
// Тот же OpId: завершённая партия возвращается из кэша, ожидающая
// async-задача продолжает поллинг, неотправленный запрос переиспользует ключ
Status := Provider.SignHashBatch(Requests, OpId, 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 и длиной соли. X.690 §8.19.4 сворачивает первые две дуги OID в одно значение (40 × первая + вторая), и под корнем 2 вторая дуга выше 39 выталкивает его за 127, где нужна base-128 многобайтовая форма. Ни один OID SHA-2 этот путь не трогает, поскольку 2.16 сворачивается в 96, поэтому энкодер может пройти все тесты SHA-2 и всё равно быть неверным. Провайдер пишет многобайтовую форму и докладывает кривой OID собственным EHPDFCSCSignatureProviderError, а не EConvertError
Почему ретрай запроса не создаёт вторую подпись?
THPDFCSCSignatureProvider даёт каждому ретраящемуся вызову детерминированный idempotency key и кэширует завершённые результаты, поэтому ретрай после потерянного ответа возвращает оригинальные подписи вместо запроса новых у HSM. Ключ — это csc- плюс hex SHA-256 идентификатора операции и фазы протокола, причём для авторизации и signHash фаза включает отпечаток партии. Хеширование вместо усечения держит ключ фиксированной длины и не даёт двум длинным идентификаторам операций с общим префиксом столкнуться
Политика ретраев в общем пути запроса узка:
- HTTP 401 принуждает ровно одно обновление токена через колбэк access-токена, и запрос повторяется один раз; второй 401 финален, а без колбэка токена финален первый
- Прочие ответы 4xx и
ctsPermanentFailureкончают вызов сspsProviderError, иerror_description(илиerror) сервиса попадает вLastError - 408, 429, 5xx и
ctsTemporaryFailureретраятся доRetryLimit(по умолчанию 2) с ожиданиемRetry-Afterили удваивающегося на каждой попыткеRetryBaseDelayMS(100 мс), с потолкомMaxRetryAfterMS(5000 мс) - Ожидания идут срезами по 25 мс, проверяющими
Cancel, поэтому пользователь, прервавший операцию, не сидит пять секунд в back-off signHashретраится, только когда включёнEnableIdempotency; выключите — и таймаут после отправки финален, потому что никто не может сказать, был ли хеш уже подписан
Асинхронное подписание (operationMode «A», дефолт, с validity_period равным AsyncValidityPeriodMS, 60000 мс) добавляет ещё одну защиту. responseID сохраняется до того, как провайдер начнёт поллить signatures/signPolling, поэтому повторный вызов с тем же идентификатором операции подхватывает ожидающую задачу вместо повторной отправки хеша. Завершённые партии кэшируются на экземпляр провайдера, до MaxOperationCacheEntries (128), и после рестарта этого кэша нет. Idempotency key выживает, потому что выводится, а не случаен: перезапущенный процесс, переиспользующий свой идентификатор операции, шлёт тот же ключ, а дедуплицирует ли по нему сервис — часть контракта сервиса
Всё это висит на идентификаторе операции, а параметр легко оставить пустым. Передайте '' в SignHashBatch — и провайдер откатится к OperationIdentifier на запросах, затем к новому GUID. Новый GUID на каждый вызов значит, что ваш собственный цикл ретраев стартует новую операцию каждый раз: новый слот кэша, новый idempotency key и, возможно, второй OTP-вопрос. Он же значит, что Provider.Cancel нечего нацеливать, ведь ваш код GUID не видел. Выводите идентификатор из того, что вы уже храните, — номера партии или ID документа — и держите EnableIdempotency в его дефолте True. Если сервис отвергает заголовок, уберите его в транспорте вместо выключения опции
Встройка CSC-подписи в PDF
Передайте провайдера в HPDFCMSSignPDFStreamWithProvider вместе с сертификатом конечного владельца из GetCertificateChain; HotPDF строит CMS SignedData, а провайдер подписывает дайджест подписанных атрибутов. Входному PDF нужны плейсхолдеры /ByteRange и /Contents, которые пишет THPDFPage.AddSignedSignatureField, — ровно как в workflow подписания 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;
Для размера плейсхолдера EstimateSignatureSize возвращает EstimatedSignatureBytes, если вы его задали, иначе размер модуля RSA, выведенный из длины ключа в credentials/info. Для ключей ECDSA он отвечает spsUnsupported, и оценщик плейсхолдера откатывается к сертификату, поэтому собственное задание EstimatedSignatureBytes даёт предсказуемый результат. HPDFCMSSignPDFStreamWithProviderAutoSize пересобирает и переподписывает, когда плейсхолдер оказался мал, и делает это только для провайдеров, рекламирующих spcSafeSignRetry. THPDFCSCSignatureProvider рекламирует его при включённом EnableIdempotency — ещё одна причина не трогать этот дефолт
Для PAdES-B-T TimestampDigest спрашивает у того же сервиса токен RFC 3161 через signatures/timestamp, с опциональной nonce и потолком размера MaxTimestampBytes (1 МБ)
Решения политики, которые остаются в вашем коде
Провайдер подписывает дайджесты. Ed25519 и Ed448 в pure-режиме отдают провайдеру подписи всё сообщение подписанных атрибутов (sikMessage), и валидатор партии отвергает это как кривое, потому что signHash по определению хешовый. Под Free Pascal юнит провайдера собирается с обычными типами функций вместо анонимных методов, поэтому SignHashBatch и TimestampDigest там вызываемы; встройка результата в PDF через HPDFCMSSignPDFStreamWithProvider — Delphi-путь
Провайдер также оставляет политику вам. CredentialInfo докладывает KeyStatus, CertificateStatus, AuthorizationMode, SCAL и MultiSignatures, но отключённый ключ или кредитеншал SCAL1 — отвергать ли его, решаете вы. Проверяйте эти поля сразу после RefreshCredentialInfo и прежде чем подписант вообще увидит OTP-вопрос; отказ сервиса после того, как подписант набрал код, — опыт хуже, чем ясная ошибка в начале
Один экземпляр провайдера гоняет одну партию за раз, потому что SignHashBatch, RefreshCredentialInfo и TimestampDigest делят лок, чтобы два потока не гнались за одной SAD. Пропускная способность приходит от более крупных партий — до лимита multisign кредитеншала. Считается ли итоговая подпись квалифицированной, решают трастовый сервис и выпущенный им кредитеншал
CSC-провайдер идёт вместе с конструкторами CMS и PAdES и локальными и PKCS#11 провайдерами подписи в HotPDF Delphi PDF component, поэтому удалённое, токен-базированное и файловое подписание кормят одни и те же вызовы подписания PDF