مقاله فنی

امضای ابری PDF در Delphi با HotPDF CSC

HotPDF اسناد PDF را با کلید خصوصی‌ای که یک سرویس راه دور Cloud Signature Consortium (CSC) نگه می‌دارد امضا می‌کند، از طریق THPDFCSCSignatureProvider، یک signature provider که CSC API را می‌راند — credential info، authorization، signatures/signHash و polling — در حالی که اپلیکیشن Delphi تو ترابری HTTP و access token مربوط به OAuth را تأمین می‌کند. کلید هرگز HSM سرویس را ترک نمی‌کند

این کم‌کم تنها راه گرفتن یک کلید امضای qualified است. trust service providerها endpoint مربوط به CSC و یک OAuth client می‌دهند، نه یک فایل PFX یا یک USB token، پس چیزی نیست که مثل امضای cert store ویندوز از طریق CNG و CAPI در یک certificate store محلی بارگذاری شود. یکپارچه‌سازی ساده‌لوحانه به شیوه‌های قابل‌پیش‌بینی شکست می‌خورد: یک فراخوانی signHash timeout می‌خورد و retry همان قرارداد را دوبار امضا می‌کند، یا یک دستهٔ چهل فاکتوری چهل رمز یک‌بارمصرف راه می‌اندازد چون هر هش جداگانه مجاز شده بود. بیشتر کاری که provider می‌کند دفاع در برابر همین دو شکست است

چرا HotPDF کار HTTP را به اپلیکیشن تو واگذار می‌کند؟

چون transport دقیقاً همان‌جایی است که هر استقرار با بقیه فرق دارد. پراکسی‌ها، TLS pinning، گواهی‌های کلاینت، OAuth vaultهای سازمانی و سیاست لاگ همه در لایهٔ HTTP زندگی می‌کنند، پس THPDFCSCSignatureProvider وضعیت پروتکل را رهبری می‌کند و به‌ازای هر درخواست یک تابع THPDFCSCTransport را صدا می‌زند. provider یک THPDFCSCTransportRequest با Method (همیشه POST) و URL کامل ساخته‌شده از ServiceBaseURL به‌علاوهٔ مسیر endpoint و یک هدر bearer Authorization آماده و ContentType و Body از جنس JSON و یک IdempotencyKey و شمارهٔ Attempt و MaxResponseBytes به تو می‌دهد. تو یک THPDFCSCTransportResponse را با StatusCode و Body و RetryAfterMS پر می‌کنی و یکی از ctsSuccess یا ctsTemporaryFailure یا ctsPermanentFailure یا ctsCancelled را برمی‌گردانی

نمودار مرز transport در CSC در HotPDF: THPDFCSCSignatureProvider پروتکل را رهبری می‌کند و به کد تو یک THPDFCSCTransportRequest با متد POST و URL کامل و هدر bearer Authorization آماده و body از جنس JSON و یک IdempotencyKey و شمارهٔ تلاش می‌دهد، و تو StatusCode و Body و RetryAfterMS به‌علاوهٔ یکی از چهار مقدار وضعیت cts را برمی‌گردانی، در حالی که کلید هرگز HSM را ترک نمی‌کند
provider کدهای وضعیت را خودش دسته‌بندی می‌کند، پس transportی که یک 503 جواب‌گرفته را به شکست دائمی تبدیل کند بی‌سروصدا منطق retry را از کار می‌اندازد، در حالی که پراکسی‌ها و سیاست 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: قابل retry
        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 کدهای وضعیت را خودش دسته‌بندی می‌کند و transportی که یک 429 را به ctsPermanentFailure تبدیل کند بی‌سروصدا منطق retry را که پایین‌تر توضیح داده شد از کار می‌اندازد. constructor در جهت دیگر سخت‌گیر است — وقتی transport غایب باشد یا CredentialID خالی باشد یا نه یک AccessToken نه callback توکن داده شده باشد یا بودجه بیرون بازه باشد یا ServiceBaseURL به‌جای HTTPS چیز دیگری باشد EHPDFCSCSignatureProviderError می‌دهد. http:// ساده فقط با AllowInsecureHTTP پذیرفته می‌شود که جایش یک رانر تست است و هیچ‌جای دیگر

SAD چیست و چرا HotPDF بعد از یک بار استفاده دورش می‌اندازد؟

THPDFCSCSignatureProvider با Signature Activation Data (SAD) مثل یک چیز یک‌بارمصرف رفتار می‌کند: همان لحظه که signatures/signHash پذیرفته می‌شود از وضعیت provider پاک می‌شود، حتی وقتی خود امضا بعداً از طریق polling ناهمزمان می‌رسد. SAD مدرک سرویس است که امضاکننده این هش‌های خاص را تأیید کرده، و SADی که در حافظه درنگ کند یک مجوز است که منتظر خرج شدن روی سند اشتباه می‌نشیند

با پیش‌فرض‌های THPDFCSCOptions.Default — یعنی RequireSAD و AutoAuthorize هر دو True — provider یک بار credentials/info را بارگذاری می‌کند، از THPDFCSCAuthenticationCallback تو مقدارهای authData را می‌خواهد (یک OTP، یک PIN، هر چه بلوک auth مربوط به credential بخواهد) و credentials/authorize را پست می‌کند. یک 200 مستقیماً SAD را حمل می‌کند؛ یک 202 دستگیره‌ای حمل می‌کند که تا MaxPollAttempts (60) بار با فاصلهٔ PollIntervalMS (250 ms) از طریق credentials/authorizeCheck پول می‌شود. callback حداکثر 32 مقدار برمی‌گرداند، هر کدام با شناسهٔ غیرخالی حداکثر 256 بایتی و مقداری حداکثر 4,096 بایتی. اگر شبکه قبل از پذیرش signHash بریزد، یک SAD که خودکار گرفته شده نگه داشته می‌شود تا همان دسته بدون پرسیدن دوباره از امضاکننده قابل‌retry باشد

نمودار چرخهٔ عمر SAD در HotPDF: با RequireSAD و AutoAuthorize provider یک بار credentials/info را بارگذاری می‌کند، از callback احراز هویت مقدارهای OTP یا PIN را می‌خواهد، credentials/authorize را پست می‌کند، وقتی جواب 202 باشد تا 60 بار با فاصلهٔ 250 میلی‌ثانیه credentials/authorizeCheck را پول می‌کند، و همان لحظه که signatures/signHash پذیرفته شد Signature Activation Data را پاک می‌کند، در حالی که SAD گرفته‌شده را تا وقتی شبکه قبل از پذیرش ریخته نگه می‌دارد
SADی که در حافظه درنگ کند مجوزی است که منتظر خرج شدن روی سند اشتباه می‌نشیند، و SAD از پیش‌تنظیم‌شده‌ای که از طریق options پاس داده می‌شود فقط برای یک درخواست تک‌هشی به کار می‌رود
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD، AutoAuthorize، حالت ناهمزمان، 2 بار retry
  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 client خودت؛ ForceRefresh بعد از جواب 401 سرویس True است
      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   // رابط کاربری خودت
        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 برای حداکثر MaxBatchSignatures (64) تا digest، یک credentials/authorize و یک signatures/signHash می‌فرستد و هر دو body را از همان آرایه می‌سازد تا numSignatures و ترتیب hashes و hashAlgorithmOID در دو فراخوانی یکسان باشند. همان تطابقی است که مدل multisign مربوط به CSC می‌خواهد. متد تک‌هشی Sign را چهل بار بچرخان و چهل مجوز می‌گیری؛ یک authorize و یک signHash ناسازگار بفرست و سرویس ممکن است SAD را خرج دستهٔ اشتباه کند

قبل از هر ترافیکی روی شبکه، provider دسته را اعتبارسنجی می‌کند. هر درخواست باید یک digest (sikDigest) از 1 تا 1,024 بایت با یک OID digest باشد و همهٔ درخواست‌ها باید یک OID الگوریتم امضا و یک OID digest و برای RSASSA-PSS یک طول salt مشترک داشته باشند. یک دستهٔ چند-هشی credentials/info را هم بارگذاری می‌کند و وقتی مقدار multisign مربوط به credential از دسته کوچک‌تر باشد spsUnsupported برمی‌گرداند. بعد SAD به یک اثر انگشت دسته پین می‌شود — یک SHA-256 روی یک برچسب نسخه، تعداد و به‌ازای هر درخواست OID الگوریتم و OID digest و الگوریتم و طول 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 هم می‌فرستد، یعنی ساختار RSASSA-PSS-params به‌صورت DER و base64 با الگوریتم هش و MGF1 و طول salt. ساختنش یعنی کدگذاری OIDها، و نسخهٔ 2.748.5 گوشه‌ای از این را fix کرد: X.690 §8.19.4 دو arc اول را در یک مقدار تاشده (40 × اول + دوم)، و زیر ریشهٔ 2 یک arc دوم بالای 39 آن مقدار را از 127 می‌گذراند، جایی که به شکل چندبایتی پایه-128 نیاز پیدا می‌کند که بیلدهای قبلی اعمالش نمی‌کردند. هیچ OID مربوط به SHA-2 تحت‌تأثیر نیست — 2.16 به 96 تاشده می‌شود — اما حالا یک OID بدفرم به‌جای EConvertError خطای مخصوص خود provider را می‌دهد

چرا یک درخواست دوباره‌شده امضای دومی تولید نمی‌کند؟

THPDFCSCSignatureProvider کاری می‌کند هر فراخوانی قابل‌retry یک کلید idempotency قطعی حمل کند و نتایج کامل‌شده را کش می‌کند، پس یک retry بعد از گم شدن جواب، امضاهای اصلی را برمی‌گرداند به‌جای آنکه از HSM امضاهای تازه بخواهد. کلید به‌شکل csc- به‌علاوهٔ هگز SHA-256 شناسهٔ عملیات و فاز است و فاز برای هر دو authorize و signHash اثر انگشت دسته را در خود جای می‌دهد. هش کردن به‌جای بریدن مهم است: دو شناسهٔ عملیات بلند که پیشوند مشترک دارند زیر بریدن برخورد می‌کنند، در حالی که یک کلید محتوا-آدرس با طول ثابت در طول تلاش‌ها یکتا و پایدار می‌ماند

سیاست retry در مسیر درخواست مشترک عمداً باریک است:

  • HTTP 401 دقیقاً یک بار refresh توکن را از طریق callback توکن دسترسی تحمیل می‌کند و بعد وقتی callback توکن دسترسی assign شده باشد درخواست یک بار تکرار می‌شود؛ یک 401 دوم قطعی است
  • بقیهٔ جواب‌های 4xx و ctsPermanentFailure فراخوانی را با spsProviderError تمام می‌کنند و error_description سرویس در LastError می‌نشیند
  • 408 و 429 و 5xx و ctsTemporaryFailure تا RetryLimit (پیش‌فرض 2) retry می‌شوند با انتظار برای Retry-After یا RetryBaseDelayMS × 2attempt (پایهٔ 100 ms) که سقفش MaxRetryAfterMS (5,000 ms) است
  • انتظارها در برش‌های 25 میلی‌ثانیه‌ای اجرا می‌شوند که Cancel را بررسی می‌کنند، پس کاربری که لغو می‌کند پنج ثانیه back-off را تحمل نمی‌کند
  • signHash فقط تا وقتی EnableIdempotency روشن است retry می‌شود؛ خاموشش کن و یک timeout بعد از ارسال قطعی است، چون هیچ‌کس نمی‌تواند بگوید کلید قبلاً استفاده شده یا نه
نمودار سیاست retry در HotPDF: هر فراخوانی قابل‌retry یک کلید idempotency قطعی به‌شکل csc- حمل می‌کند که از شناسهٔ عملیات و فاز هش شده، HTTP 401 دقیقاً یک بار refresh توکن را تحمیل می‌کند، بقیهٔ جواب‌های 4xx با spsProviderError تمام می‌شوند، و 408 و 429 و 5xx یا یک شکست موقت transport تا RetryLimit برابر 2 retry می‌شوند با انتظار برای Retry-After یا backoff نمایی با سقف 5,000 ms
دسته‌های کامل‌شده به‌کمک شناسهٔ عملیات و credential و اثر انگشت کش می‌شوند و در حالت ناهمزمان responseID ذخیره‌شده به یک فراخوانی تکراری اجازه می‌دهد polling را ادامه بدهد به‌جای ارسال دوبارهٔ هش

امضای ناهمزمان (operationMode برابر "A"، یعنی پیش‌فرض) یک نگهبان دیگر هم اضافه می‌کند: responseID قبل از پول کردن signatures/signPolling ذخیره می‌شود، پس یک فراخوانی تکراری با همان شناسهٔ عملیات polling را ادامه می‌دهد به‌جای ارسال دوباره. دسته‌های کامل‌شده در یک cache به کلید شناسهٔ عملیات و credential و اثر انگشت می‌نشینند، با سقف MaxOperationCacheEntries (128) و به‌شکل کپی عمیق برگردانده می‌شوند. آن cache داخل instance مربوط به provider زندگی می‌کند و از یک ری‌استارت جان سالم به در نمی‌برد. کلید idempotency می‌برد، چون مشتق شده نه تصادفی، پس یک فرایند ری‌استارت‌شده که شناسهٔ عملیاتش را دوباره استفاده کند همان کلید را می‌فرستد — اینکه سرویس روی آن deduplicate کند قول سرویس است، نه HotPDF

چطور یک امضای CSC را داخل PDF بگذاریم؟

provider را همراه با گواهی end-entity از GetCertificateChain به HPDFCMSSignPDFStreamWithProvider بده؛ HotPDF مربوط به CMS یعنی SignedData را می‌سازد و provider digestِ signed attributes را امضا می‌کند. PDF ورودی به جای‌نگهدار /ByteRange و /Contents نیاز دارد که THPDFPage.AddSignedSignatureField می‌نویسد، دقیقاً مثل گردش‌کار امضای PAdES در HotPDF، و مدل provider همان است که در 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 از EstimateSignatureSize می‌گذرد که وقتی مقدارش را ست کرده باشی EstimatedSignatureBytes را برمی‌گرداند و در غیر این صورت اندازهٔ modulus مربوط به RSA را از طول کلید credential. برای ECDSA خودت EstimatedSignatureBytes را ست کن، وگرنه تخمین spsUnsupported گزارش می‌کند. واریانت امضای خود-اندازه وقتی جای‌نگهداری کوچک از آب درآمد دوباره امضا می‌کند و این کار را فقط برای providerهایی می‌کند که spcSafeSignRetry را اعلان کنند — که THPDFCSCSignatureProvider فقط تا وقتی EnableIdempotency روشن است اعلانش می‌کند. برای گردش‌کارهای PAdES-B-T، TimestampDigest از همان سرویس از طریق signatures/timestamp یک توکن timestamp می‌خواهد با سقف MaxTimestampBytes (1 MB)

provider مربوط به CSC چه کاری نمی‌کند؟

پیام امضا نمی‌کند، فقط digest. Ed25519 و Ed448 در حالت خالص، کل پیام signed attributes را (sikMessage) به provider می‌دهند و اعتبارسنج دسته آن را به‌عنوان بدفرم رد می‌کند، چون signHash بنا به تعریف هش‌محور است. واحد provider زیر Free Pascal با نوع تابع ساده به‌جای متدهای بی‌نام کامپایل می‌شود، اما builderهای CMS رانده‌شده با provider امروز زیر FPC خطا می‌دهند، پس embed کردن یک امضای CSC در PDF مسیر مخصوص Delphi است

سیاست هم تصمیم نمی‌گیرد. CredentialInfo وضعیت کلید و وضعیت گواهی و حالت مجوزدهی و سطح SCAL و سقف multisign را گزارش می‌کند، اما provider خودش یک کلید غیرفعال یا credential از نوع SCAL1 را رد نمی‌کند — قبل از اینکه OTP prompt را به امضاکننده نشان بدهی این‌ها را بررسی کن. و هر instance provider در هر لحظه یک دسته امضا می‌کند: SignHashBatch داخلی سریالایز می‌شود تا دو thread نتوانند سر یک SAD با هم مسابقه بدهند، یعنی توان‌عملیاتی از batch کردن می‌آید نه از به اشتراک گذاشتن یک provider بین threadهای کارگر. اینکه امضای حاصل qualified باشد به trust service و credential آن بستگی دارد، نه به کتابخانه‌ای که هش را به آنجا رساند

provider مربوط به CSC، builderهای CMS و PAdES و providerهای محلی و PKCS#11 همه در کامپوننت PDF در Delphi از HotPDF عرضه می‌شوند