مقال تقني

التوقيع البعيد CSC في HotPDF: تواقيع PDF سحابية في Delphi

يوقّع HotPDF مستندات PDF بمفتاح خاص يحتفظ به خدمة Cloud Signature Consortium‏ (CSC) بعيدة عبر THPDFCSCSignatureProvider، مزود تواقيع يقود واجهة CSC — معلومات الاعتماد، والتصريح، و signatures/signHash، والاستقصاء — بينما يوفر تطبيق Delphi لديك نقل HTTP ورمز وصول OAuth. المفتاح لا يغادر HSM الخدمة أبداً

ذلك صار يوماً بعد يوم الطريقة الوحيدة للحصول على مفتاح توقيع مؤهل أصلاً. مزودو خدمات الثقة يسلّمون نقطة نهاية CSC وعميل OAuth، لا ملف PFX ولا رمزاً USB، فلا شيء يُحمَّل في مخزن شهادات محلي بالطريقة التي يفعلها التوقيع من مخزن شهادات Windows عبر CNG و CAPI. التكامل الساذج يفشل بطرق متوقعة: استدعاء signHash ينقضي الوقت وإعادة المحاولة توقّع العقد نفسه مرتين، أو دفعة من أربعين فاتورة تطلق أربعين كلمة سر أحادية الاستخدام لأن كل هُضم وُضّح منفرداً. معظم ما يفعله المزود هو الدفاع عن هاتي الحالتين

لماذا يترك HotPDF الـ HTTP لتطبيقك؟

لأن النقل بالضبط هو حيث تختلف كل عملية نشر. الوكلاء وتثبيت TLS وشهادات العميل وخزائن OAuth المؤسسية وسياسة التسجيل كلها تسكن طبقة HTTP، لذا ينظم THPDFCSCSignatureProvider حالة البروتوكول ويستدعي دالة THPDFCSCTransport عن كل طلب. يسلّمك المزود THPDFCSCTransportRequest بـ Method‏ (‏POST دائماً)، و URL كاملاً مبني من ServiceBaseURL زائد مسار نقطة النهاية، وترويسة Authorization حاملة جاهزة، و ContentType، و Body بصيغة JSON، و IdempotencyKey، ورقم Attempt، و MaxResponseBytes. وتملأ أنت THPDFCSCTransportResponse بـ StatusCode و Body و RetryAfterMS، وتعيد واحدة من ctsSuccess أو ctsTemporaryFailure أو ctsPermanentFailure أو ctsCancelled

مخطط حد النقل CSC في HotPDF: ينظم THPDFCSCSignatureProvider البروتوكول ويسلم كودك THPDFCSCTransportRequest بأسلوب POST و URL كامل وترويسة 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 بيانات تفعيل التوقيع‏ (SAD) بوصفها أحادية الاستخدام: تُمسح من حالة المزود لحظة قبول signatures/signHash، حتى حين يصل التوقيع نفسه لاحقاً عبر استقصاء غير متزامن. الـ SAD إثبات الخدمة بأن الموقّع وافق على هذه الهضمات بعينها، و SAD تتمدد في الذاكرة تصريح ينتظر أن يُصرف على المستند الخطأ

وبالإعدادات الافتراضية من THPDFCSCOptions.Default — ‏RequireSAD و AutoAuthorize كلاهما True — يحمل المزود credentials/info مرة واحدة، ويسأل THPDFCSCAuthenticationCallback لديك عن قيم authData‏ (كلمة سر أحادية، أو رقم PIN، أو ما يتطلبه كتلة auth للاعتماد)، ويرسل credentials/authorize. ‏200 تحمل الـ SAD مباشرة؛ و 202 تحمل معرفاً يُستقصى عبر credentials/authorizeCheck حتى MaxPollAttempts‏ (60) مرة بفواصل PollIntervalMS‏ (250 ms). وقد يعيد النداء 32 قيمة على الأكثر، كل منها بمعرف غير فارغ حتى 256 بايتاً وقيمة حتى 4,096 بايتاً. وإن انقطعت الشبكة قبل قبول signHash، حُفظ SAD مُحصَّل تلقائياً حتى تُعاد الدفعة نفسها دون سؤال الموقّع مجدداً

مخطط دورة حياة SAD في HotPDF: مع RequireSAD و AutoAuthorize يحمل المزود credentials/info مرة، ويسأل نداء المصادقة عن قيم OTP أو PIN، ويرسل credentials/authorize، ويستقصي credentials/authorizeCheck حتى 60 مرة بفواصل 250 ms حين تكون الإجابة 202، ويمسح بيانات تفعيل التوقيع لحظة قبول signatures/signHash، محفظاً SAD محصلاً حين انقطعت الشبكة قبل القبول
‏SAD تتمدد في الذاكرة تصريح ينتظر أن يُصرف على المستند الخطأ، و SAD سابقة الضبط ممررة عبر الخيارات تستخدم لطلب هضم واحد فقط
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD و AutoAuthorize ووضع غير متزامن وإعادتان
  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 صحيحة بعد أن أجابت الخدمة 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   // واجهتك
        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، بنية RSASSA-PSS-params بترميز base64 DER تحوي خوارزمية الهضم و MGF1 وطول الملح. وبناؤها يعني ترميز معرفات OID، وأصلح الإصدار 2.748.5 زاوية من ذلك: تطوي X.690 §8.19.4 القوسين الأولين في قيمة واحدة (40 × الأول + الثاني)، وتحت جذر 2 يدفع القوس الثاني فوق 39 تلك القيمة إلى ما بعد 127، حيث تحتاج صيغة الأساس 128 متعددة البايتات التي لم تكن البنيات الأقدم تطبقها. لا OID من عائلة SHA-2 يتأثر — فـ 2.16 يتطوى إلى 96 — لكن OID مشوه يرفع الآن خطأ المزود الخاص بدل EConvertError

لماذا لا ينتج عن طلب معاد محاولته توقيع ثانٍ؟

يجعل THPDFCSCSignatureProvider كل استدعاء قابل لإعادة المحاولة يحمل مفتاح idempotency حتمياً ويخزن النتائج المنجزة، فإعادة محاولة بعد استجابة ضائعة تعيد التواقيع الأصلية بدل طلب تواقيع جديدة من الـ HSM. المفتاح csc- يليه SHA- hex للمعرف العملي والطور، ويضمّن الطريق بصمة الدفعة للتصريح ولـ signHash كليهما. البصم بدل البتر يهم: معرفان عمليان طويلان يتشاركان بادئة سيتصادمان تحت البتر، بينما مفتاح ذو طول ثابت معنون بالمحتوى يبقى فريداً ومستقراً عبر المحاولات

سياسة إعادة المحاولة في مسار الطلب المشترك ضيقة عمداً:

  • ‏HTTP 401 يفرض تجديد رمز واحداً بالضبط عبر نداء رمز الوصول، ثم يكرر الطلب مرة واحدة حين يكون نداء رمز الوصول مسنداً؛ و 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، فمستخدم يجهض لا يجلس خمسة ثوانٍ كاملة
  • ‏signHash تعاد محاولتها فقط ما دام EnableIdempotency مفعلاً؛ أطفئه وصار انقضاء الوقت بعد الإرسال نهائياً، لأن أحداً لا يستطيع أن يعرف هل استُخدم المفتاح فعلاً
مخطط سياسة إعادة المحاولة في HotPDF: كل استدعاء قابل لإعادة المحاولة يحمل مفتاح idempotency حتمياً مسبوقاً بـ csc- مبصوماً من المعرف العملي والطور، و HTTP 401 يفرض تجديد رمز واحداً بالضبط، وبقية إجابات 4xx تنتهي بـ spsProviderError، و 408 و 429 و 5xx أو فشل نقل مؤقت تعاد محاولتها حتى RetryLimit قدره 2 بانتظار Retry-After أو تراجع أسي بسقف 5,000 ms
الدفعات المنجزة تُخزن بالمعرف العملي والاعتماد والبصمة، وفي الوضع غير المتزامن يجعل responseID المخزن استدعاءً مكرراً يستأنف الاستقصاء بدل إعادة إرسال الهضم

التوقيع غير المتزامن‏ (operationMode بقيمة "A"، وهو الافتراضي) يضيف حراساً آخر: يخزن responseID قبل استقصاء signatures/signPolling، فاستدعاء مكرر بالمعرف العملي نفسه يستأنف الاستقصاء بدل إعادة الإرسال. وتجلس الدفعات المنجزة في ذاكرة مفاتيحها المعرف العملي والاعتماد والبصمة، بسقف MaxOperationCacheEntries‏ (128) وتعاد نسخاً عميقة. تلك الذاكرة تعيش في نسخة المزود ولا تنجو من إعادة تشغيل. أما مفتاح idempotency فينجو، لأنه مشتق لا عشوائي، فعملية معاد تشغيلها تعيد استخدام معرفها العملي ترسل المفتاح نفسه — وهل تزيل الخدمة التكرار عليه وعد الخدمة، لا وعد 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 كلها تشحن في مكون PDF من HotPDF لـ Delphi