Технічна стаття

CSC-підписання HotPDF: хмарні підписи PDF у Delphi

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

Діаграма транспортної межі CSC у HotPDF: THPDFCSCSignatureProvider оркеструє протокол і віддає вашому коду THPDFCSCTransportRequest із методом POST, повним URL, готовим заголовком Authorization bearer, 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, ні 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 зберігається, щоб той самий пакет можна було повторити, не питаючи підписувача знову

Діаграма життєвого циклу SAD у HotPDF: з RequireSAD і AutoAuthorize провайдер один раз завантажує credentials/info, питає authentication callback про значення 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 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; вимкніть — і таймаут після надсилання фінальний, бо ніхто не скаже, чи ключ уже використали
Діаграма політики ретраїв HotPDF: кожен повторюваний виклик несе детермінований idempotency-ключ csc-, захешований з ідентифікатора операції та фази, HTTP 401 форсує рівно одне оновлення токена, інші відповіді 4xx закінчуються spsProviderError, а 408, 429, 5xx чи тимчасовий транспортний збій повторюються до RetryLimit 2 з чеканням Retry-After чи експоненційного back-off зі стелею 5 000 мс
Завершені пакети кешуються за ідентифікатором операції, credential-ом і відбитком, а в асинхронному режимі збережений responseID дозволяє повтореному виклику продовжити полінг замість перевисилання хеша

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