Техническая статья

CSC remote signing в HotPDF: облачные подписи PDF из Delphi

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

Схема границы транспорта CSC в HotPDF: THPDFCSCSignatureProvider дирижирует протоколом и вручает вашему коду THPDFCSCTransportRequest с методом POST, полным URL, готовым bearer-заголовком Authorization, JSON-телом, IdempotencyKey и номером попытки, а вы возвращаете StatusCode, Body, RetryAfterMS и одно из четырёх значений cts, пока ключ не покидает HSM
Провайдер классифицирует коды статусов сам, поэтому транспорт, превращающий отвеченный 503 в постоянный отказ, тихо выключает логику ретраев, а прокси и TLS-политика остаются в коде, которым владеете вы
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 сохраняется, чтобы ту же партию можно было ретраить, не спрашивая подписанта снова

Схема жизненного цикла SAD в HotPDF: с RequireSAD и AutoAuthorize провайдер один раз грузит credentials/info, спрашивает у колбэка аутентификации значения OTP или PIN, шлёт credentials/authorize, поллит credentials/authorizeCheck до 60 раз с шагом 250 мс, когда ответ 202, и вычищает Signature Activation Data в момент принятия signatures/signHash, сохраняя полученную SAD, если сеть упала до принятия
SAD, застрявшая в памяти, — авторизация, ждущая, чтобы её потратили на не тот документ, а предустановленная 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; выключите — и таймаут после отправки финален, потому что никто не скажет, был ли ключ уже использован
Схема политики ретраев в HotPDF: каждый ретраящийся вызов несёт детерминированный idempotency key csc-, захешированный из идентификатора операции и фазы, HTTP 401 принуждает ровно один refresh токена, прочие ответы 4xx заканчиваются spsProviderError, а 408, 429, 5xx или временный сбой транспорта ретраятся до RetryLimit 2 с ожиданием Retry-After или экспоненциальным backoff с потолком 5 000 мс
Завершённые партии кэшируются по идентификатору операции, кредитеншалу и отпечатку, а в асинхронном режиме сохранённый responseID позволяет повторному вызову продолжить поллинг вместо повторной отправки хеша

Асинхронная подпись (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