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

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

HotPDF подписва PDF документи с частен ключ, държан от отдалечена услуга на Cloud Signature Consortium (CSC), чрез THPDFCSCSignatureProvider — signature provider, който управлява CSC API-то: credential info, authorization, signatures/signHash и pollване, докато вашето Delphi приложение доставя HTTP transport-а и OAuth access token-а. Ключът никога не напуска HSM-а на услугата

Това все повече е единственият начин изобщо да получите qualified подписващ ключ. Trust service provider-ите раздават CSC endpoint и OAuth клиент, не PFX файл или USB token, така че няма какво да се зареди в локален certificate store, както прави подписването от Windows cert store през CNG и CAPI. Наивната интеграция се проваля предвидимо: извикване на signHash отива на timeout и повторението подписва същия договор два пъти, или партида от четиридесет фактури палит четиридесет еднократни пароли, защото всеки хеш е бил оторизиран поотделно. Повечето от това, което provider-ът върши, е защита срещу тези два провала

Защо HotPDF оставя HTTP на вашето приложение?

Защото transport-ът е точно мястото, където всяко разгръщане се различава. Proxy-та, TLS pinning, client сертификати, корпоративни OAuth хранилища и logging политика живеят в HTTP слоя, затова THPDFCSCSignatureProvider оркестрира протоколното състояние и вика функция THPDFCSCTransport за всяка заявка. Provider-ът ви подава THPDFCSCTransportRequest с Method (винаги POST), пълния URL, изграден от ServiceBaseURL плюс пътя на endpoint-а, готов Authorization bearer header, ContentType, JSON Body, IdempotencyKey, номера на Attempt и MaxResponseBytes. Вие попълвате THPDFCSCTransportResponse с StatusCode, Body и RetryAfterMS и връщате едно от ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure или ctsCancelled

Диаграма на CSC transport границата в HotPDF: THPDFCSCSignatureProvider оркестрира протокола и подава на вашия код THPDFCSCTransportRequest с POST метод, пълния URL, готов Authorization bearer header, JSON тяло, IdempotencyKey и номер на опита, а вие връщате StatusCode, Body, RetryAfterMS плюс една от четирите cts стойности, докато ключът никога не напуска HSM-а
Provider-ът сам класифицира status кодовете, така че transport, който превръща отговорено 503 в постоянен провал, тихо изключва retry логиката, докато proxy-та и 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  // име на header-а, както го документира услугата
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // проблем със socket или 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. Provider-ът сам класифицира status кодовете, а transport, който превръща 429 в ctsPermanentFailure, тихо изключва retry логиката, описана по-долу. Конструкторът е строг в другата посока — вдига EHPDFCSCSignatureProviderError, когато transport липсва, CredentialID е празен, нито AccessToken, нито token callback е подаден, budget е извън диапазона или ServiceBaseURL не е HTTPS. Гол http:// се приема само с AllowInsecureHTTP, което принадлежи на тестов стенд и никъде другаде

Какво е SAD-ът и защо HotPDF го изхвърля след една употреба?

THPDFCSCSignatureProvider третира Signature Activation Data (SAD) като еднократна: изчиства се от състоянието на provider-а в момента, в който signatures/signHash е прието, дори самият подпис да пристига по-късно чрез асинхронно pollване. SAD-ът е доказателството на услугата, че подписващият е одобрил точно тези хешове, а SAD, седяща в паметта, е оторизация, чакаща да бъде изразходвана за грешния документ

С подразбиранятията от THPDFCSCOptions.Default — RequireSAD и AutoAuthorize и двете True — provider-ът зарежда credentials/info веднъж, пита вашия THPDFCSCAuthenticationCallback за authData стойностите (OTP, PIN, каквото и да изисква auth блокът на credential-а) и праща credentials/authorize. 200 носи SAD-а директно; 202 носи handle, който се pollва през credentials/authorizeCheck до MaxPollAttempts (60) пъти на PollIntervalMS (250 ms). Callback-ът може да върне най-много 32 стойности, всяка с непразен ID до 256 байта и стойност до 4 096 байта. Ако мрежата падне, преди signHash да е прието, автоматично получена SAD се пази, така че същата партида може да се повтори, без да се пита подписващият пак

Диаграма на SAD жизнения цикъл в HotPDF: с RequireSAD и AutoAuthorize provider-ът зарежда credentials/info веднъж, пита authentication callback-а за OTP или PIN стойности, праща credentials/authorize, pollва credentials/authorizeCheck до 60 пъти на 250 ms при отговор 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
      // вашият 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 не може да знае за кои хешове е издадена, така че provider-ът ползва предварително зададена SAD само за заявка с един хеш. За партида с изключено AutoAuthorize provider-ът се проваля с „CSC SAD is not pinned to the requested hash batch", вместо да гадае

Как SignHashBatch подписва много документи с една оторизация?

SignHashBatch праща едно credentials/authorize и едно signatures/signHash за до MaxBatchSignatures (64) дайджеста, и строи двете тела от един и същ масив, така че numSignatures, редът на hashes и hashAlgorithmOID са идентични в двете извиквания. Това съвпадение е изисквано от CSC multisign модела. Пуснете ли single-hash метода Sign четиридесет пъти в цикъл, получавате четиридесет оторизации; пратете authorize и signHash, които не съвпадат, и услугата може да изразходва SAD-а срещу грешната партида

Преди какъвто и да е мрежов трафик provider-ът валидира партидата. Всяка заявка трябва да е дайджест (sikDigest) от 1 до 1 024 байта с digest OID, а всички заявки трябва да споделят един signature algorithm OID, един digest OID и, за RSASSA-PSS, една дължина на salt. Партида с много хешове също зарежда credentials/info и връща spsUnsupported, когато стойността multisign на credential-а е по-малка от партидата. После SAD-ът се закова към fingerprint на партидата — SHA-256 над етикет на версия, броя и, на заявка, algorithm OID, digest OID, алгоритъм, дължина на salt и digest байтове, всеки с префикс дължина. Разменете ли два хеша, това е друга партида, която иска свежа оторизация

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 provider-ът праща и signAlgoParams — base64 DER структура RSASSA-PSS-params с hash алгоритъма, MGF1 и дължината на salt. Строенето ѝ значи кодиране на OID-и, а v2.748.5 оправи ъгъл на това: X.690 §8.19.4 сгъва първите две арки в една стойност (40 × първа + втора), а под корена 2 втора арка над 39 избутва тази стойност над 127, където ѝ трябва base-128 многобайтовата форма, която по-старите build-ове не прилагаха. Никой SHA-2 OID не е засегнат — 2.16 се сгъва на 96 — но зле оформен OID вече вдига собствена грешка на provider-а вместо EConvertError

Защо повторена заявка не ражда втори подпис?

THPDFCSCSignatureProvider кара всяко повторяемо извикване да носи детерминиран idempotency ключ и кешира свършените резултати, така че повторение след изгубен отговор връща оригиналните подписи, вместо да пита HSM-а за нови. Ключът е csc-, следван от hex SHA-256 на идентификатора на операцията и фазата, а фазата вгражда fingerprint-а на партидата и за authorization, и за signHash. Хеширането вместо съкращаването има значение: два дълги operation ID, споделящи префикс, биха се сблъскали при съкращаване, докато ключ с фиксирана дължина, адресиран по съдържание, остава уникален и стабилен между опитите

Retry политиката в споделения път за заявки е тясна нарочно:

  • HTTP 401 налага точно едно опресняване на token-а през access-token callback-а, после заявката се повтаря веднъж, ако е закачен access-token callback; второ 401 е финално
  • Останалите 4xx отговори и ctsPermanentFailure приключват извикването с spsProviderError, а error_description на услугата отива в LastError
  • 408, 429, 5xx и ctsTemporaryFailure се повтарят до RetryLimit (по подразбиране 2), чакайки Retry-After или RetryBaseDelayMS × 2attempt (база 100 ms), капирано на MaxRetryAfterMS (5 000 ms)
  • Чаканията вървят на резки от 25 ms, които проверяват Cancel, така че потребител, прекъснал операцията, не седели през петсекунден back-off
  • signHash се повтаря само докато EnableIdempotency е включена; изключете ли я, timeout след подаване е финален, защото никой не може да каже дали ключът вече е ползван
Диаграма на retry политиката в HotPDF: всяко повторяемо извикване носи детерминиран csc- idempotency ключ, хеширан от идентификатора на операцията и фазата, HTTP 401 налага точно едно опресняване на token, останалите 4xx отговори свършват с spsProviderError, а 408, 429, 5xx или временен transport провал се повтарят до RetryLimit 2, чакайки Retry-After или експоненциален backoff, капиран на 5 000 ms
Свършените партиди се кешират по operation идентификатор, credential и fingerprint, а в асинхронен режим записаният responseID позволява на повторено извикване да поднови pollването, вместо да подава хеша наново

Асинхронното подписване (operationMode „A", по подразбиране) добавя още една защита: responseID се записва, преди да се pollва signatures/signPolling, така че повторено извикване със същия operation идентификатор подновява pollването, вместо да подава наново. Свършените партиди седят в кеш с ключове operation идентификатор, credential и fingerprint, капиран от MaxOperationCacheEntries (128) и връщани като дълбоки копия. Този кеш живее в инстанцията на provider-а и не оцелява при рестарт. Idempotency ключът оцелява, защото е извлечен, а не случаен, така че рестартиран процес, преизползващ своя operation идентификатор, праща същия ключ — дали услугата дедупликира по него, е обещание на услугата, не на HotPDF

Как вмъквате CSC подпис в PDF?

Подайте provider-а на HPDFCMSSignPDFStreamWithProvider заедно с end-entity сертификата от GetCertificateChain; HotPDF строи CMS SignedData, а provider-ът подписва дайджеста на signed attributes. Входният PDF има нужда от /ByteRange и /Contents placeholder, които THPDFPage.AddSignedSignatureField записва — точно както в PAdES подписващия workflow в HotPDF — а provider моделът е същият, покрит в plug-ватите 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 изброява end-entity сертификата първи
  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 placeholder-а минава през EstimateSignatureSize, който връща EstimatedSignatureBytes, ако сте го задали, а иначе размера на RSA модула от дължината на ключа на credential-а. За ECDSA задайте EstimatedSignatureBytes сами, или оценката докладва spsUnsupported. Авто-размерната подписваща варианти преизпълнява подписа, щом placeholder-ът се окаже твърде малък, и го прави само за provider-и, обявяващи spcSafeSignRetry — което THPDFCSCSignatureProvider прави само докато EnableIdempotency е включена. За PAdES-B-T workflows TimestampDigest иска timestamp token от същата услуга през signatures/timestamp, капиран на MaxTimestampBytes (1 MB)

Какво CSC provider-ът не върши?

Не подписва съобщения, само дайджести. Ed25519 и Ed448 в pure mode подават на provider-а цялото съобщение със signed attributes (sikMessage), а batch валидаторът го отхвърля като зле оформено, защото signHash по дефиниция е базиран на хеш. Provider unit-ът се компилира под Free Pascal с обикновени функционални типове вместо anonymous методи, но CMS builder-ите, водени от provider, днес вдигат грешка под FPC, така че вграждането на CSC подпис в PDF е Delphi път

Не решава и политика. CredentialInfo докладва статуса на ключа, статуса на сертификата, authorization режима, SCAL нивото и лимита multisign, но provider-ът сам няма да откаже изключен ключ или SCAL1 credential — проверете тези неща, преди да покажете на подписващия OTP prompt. И една инстанция на provider подписва една партида в даден момент: SignHashBatch е сериализиран вътрешно, така че две нишки не могат да се борят за една SAD, което значи, че throughput идва от партидиране, не от споделяне на provider между worker нишки. Дали полученият подпис е qualified, зависи от trust услугата и нейния credential, не от библиотеката, която е занесла хеша дотам

CSC provider-ът, CMS и PAdES builder-ите и локалните и PKCS#11 provider-ите излизат всичките в HotPDF Delphi PDF компонента