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

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

Запрос 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

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

Транспорту на 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-вопроса. Подписант одобрил один раз; провайдер тратит это одобрение один раз

Схема жизненного цикла 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, 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; выключите — и таймаут после отправки финален, потому что никто не может сказать, был ли хеш уже подписан
Схема политики ретраев в HotPDF: каждый ретраящийся вызов несёт детерминированный idempotency key csc-, захешированный из идентификатора операции и фазы, HTTP 401 принуждает ровно один refresh токена, прочие ответы 4xx заканчиваются spsProviderError, а 408, 429, 5xx или временный сбой транспорта ретраятся до RetryLimit 2 с ожиданием Retry-After или экспоненциальным backoff с потолком 5 000 мс
Завершённые партии кэшируются по идентификатору операции, кредитеншалу и отпечатку, а в асинхронном режиме сохранённый responseID позволяет повторному вызову продолжить поллинг вместо повторной отправки хеша

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