מאמר טכני

חתימה מרוחקת של HotPDF CSC: חתימות PDF בענן בדלפי

HotPDF חותם מסמכי PDF עם מפתח פרטי שמוחזק על ידי שירות Cloud Signature Consortium (CSC) מרוחק דרך THPDFCSCSignatureProvider, ספק חתימות שמניע את ה-CSC API — credential info, authorization, signatures/signHash ו-polling — בזמן שיישום הדלפי שלכם מספק את תעבורת ה-HTTP ואת access token של ה-OAuth. המפתח לעולם לא עוזב את ה-HSM של השירות

זו הולכת ונעשית הדרך היחידה בכלל להשיג מפתח חתימה מאושר. ספקי שירותי אמון מחלקים נקודת קצה של CSC ולקוח OAuth, לא קובץ PFX ולא token USB, ולכן אין מה לטעון אל חנות תעודות מקומית כפי שחתימה מחנות התעודות של Windows דרך CNG ו-CAPI עושה. האינטגרציה הנאיבית נכשלת בדרכים צפויות: קריאת signHash פגה בתוקף והניסיון החוזר חותם את אותו חוזה פעמיים, או שאצווה של ארבעים חשבוניות מציתה ארבעים סיסמאות חד-פעמיות כי כל hash אושר בנפרד. רוב מה שהספק עושה הוא להגן מפני שני הכשלים האלה

למה HotPDF משאירה את ה-HTTP ליישום שלכם?

כי התעבורה היא בדיוק המקום שבו כל פריסה שונה. Proxies, TLS pinning, תעודות לקוח, כספות OAuth ארגוניים ומדיניות לוגים כולם חיים בשכבת ה-HTTP, ולכן THPDFCSCSignatureProvider מתזמר את מצב הפרוטוקול וקורא לפונקציית THPDFCSCTransport עבור כל בקשה. הספק מוסר לכם THPDFCSCTransportRequest עם Method (תמיד POST), ה-URL המלא שנבנה מ-ServiceBaseURL בתוספת נתיב נקודת הקצה, כותרת bearer של Authorization מוכנה, ContentType, ה-Body של ה-JSON, 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 שנענה להפניה קבועה משתקת בשקט את לוגיקת הניסיונות החוזרים, בזמן ש-proxies ומדיניות 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);            // תקלת 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. הספק מסווג את קודי המצב בעצמו, ותעבורה שהופכת 429 אל ctsPermanentFailure משתקת בשקט את לוגיקת הניסיונות החוזרים שמתוארת להלן. ה-constructor קפדני בכיוון השני — הוא מעלה EHPDFCSCSignatureProviderError כשהתעבורה חסרה, כשה-CredentialID ריק, כשלא סופק לא AccessToken ולא callback של token, כשתקציב מחוץ לטווח, או כש-ServiceBaseURL אינו HTTPS. http:// פשוט מתקבל רק עם AllowInsecureHTTP, ששייך לריג בדיקות ולשום מקום אחר

מהו ה-SAD, ולמה HotPDF זורקת אותו אחרי שימוש אחד?

THPDFCSCSignatureProvider מתייחס אל Signature Activation Data (SAD) כחד-פעמי: הוא נמחק ממצב הספק ברגע ש-signatures/signHash מתקבל, גם כשהחתימה עצמה מגיעה מאוחר יותר דרך polling אסינכרוני. ה-SAD הוא ההוכחה של השירות שהחותם אישר את ה-hash-ים הספציפיים האלה, ו-SAD שנשאר לו בזיכרון הוא הרשאה שממתינה להיות מוצאת על המסמך הלא נכון

עם ברירות המחדל מ-THPDFCSCOptions.Default — RequireSAD ו-AutoAuthorize שניהם True — הספק טוען credentials/info פעם אחת, שואל את ה-THPDFCSCAuthenticationCallback שלכם עבור ערכי ה-authData (OTP, PIN, מה שבלוק ה-auth של ה-credential דורש), ושולח credentials/authorize. 200 נושא את ה-SAD ישירות; 202 נושא handle שנשאל דרך credentials/authorizeCheck עד MaxPollAttempts (60) פעמים בקצב של PollIntervalMS (250 ms). ה-callback רשאי להחזיר לכל היותר 32 ערכים, כל אחד עם מזהה לא ריק של עד 256 בתים וערך של עד 4,096 בתים. אם הרשת נופלת לפני ש-signHash מתקבל, SAD שהושג אוטומטית נשמר כדי שאותה אצווה תוכל לנסות שוב בלי לשאול את החותם שוב

דיאגרמת מחזור חיים של SAD ב-HotPDF: עם RequireSAD ו-AutoAuthorize הספק טוען credentials/info פעם אחת, שואל את callback האימות עבור ערכי OTP או PIN, שולח credentials/authorize, שואל credentials/authorizeCheck עד 60 פעמים ב-250 ms כשהתשובה היא 202, ומנקה את ה-Signature Activation Data ברגע ש-signatures/signHash מתקבל, תוך שמירה על SAD שהושג כשהרשת נפלה לפני הקבלה
SAD שנשאר לו בזיכרון הוא הרשאה שממתינה להיות מוצאת על המסמך הלא נכון, ו-SAD מוגדר מראש שהועבר דרך האפשרויות משמש עבור בקשת hash יחיד בלבד
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   // ממשק המשתמש שלכם
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

SAD שאתם מעבירים בעצמכם דרך Options.SAD מתנהג אחרת, ובמתכוון. HotPDF לא יכולה לדעת עבור אילו hash-ים הוא הונפק, ולכן הספק משתמש ב-SAD מוגדר מראש רק עבור בקשת hash יחיד. עבור אצווה עם AutoAuthorize כבוי, הספק נכשל עם "CSC SAD אינו נעוץ לאצוות ה-hash המבוקשת" במקום לנחש

איך SignHashBatch חותם מסמכים רבים עם הרשאה אחת?

SignHashBatch שולח credentials/authorize אחד ו-signatures/signHash אחד עבור עד MaxBatchSignatures (64) digest-ים, ובונה את שני הגופים מאותו מערך כך ש-numSignatures, סדר ה-hashes וה-hashAlgorithmOID זהים בשתי הקריאות. ההתאמה הזאת היא מה שמודל ה-multisign של CSC דורש. לולאה של מתודת ה-Sign החד-hash ארבעים פעמים מניבה ארבעים הרשאות; לשלוח authorize ו-signHash שאינם מסכימים יכול לגרום לשירות לצרוך את ה-SAD על האצווה הלא נכונה

לפני כל תנועת רשת, הספק מאמת את האצווה. כל בקשה חייבת להיות digest (sikDigest) בן בין 1 ל-1,024 בתים עם OID של digest, וכל הבקשות חייבות לחלוק OID אחד של אלגוריתם חתימה, OID אחד של digest, ועבור RSASSA-PSS, אורך מלח אחד. אצווה מרובת hash גם טוענת credentials/info ומחזירה spsUnsupported כשערך ה-multisign של ה-credential קטן מהאצווה. ה-SAD נעוץ אז אל טביעת אצבע של האצווה — SHA-256 על תווית גרסה, המספר, ולכל בקשה: OID האלגוריתם, OID ה-digest, האלגוריתם, אורך המלח ובתי ה-digest, כל אחד עם קידומת אורך. להחליף שני hash-ים וזו אצווה אחרת שזקוקה להרשאה טרייה

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 של DER ב-base64 עם אלגוריתם ה-hash, MGF1 ואורך המלח. לבנות אותו פירושו לקודד OID-ים, וגרסה 2.748.5 תיקנה פינה של זה: X.690 §8.19.4 מקפל את שני הקשתות הראשונות לערך אחד (40 × הראשונה + השנייה), ותחת השורש 2 קשת שנייה מעל 39 דוחפת את הערך מעבר ל-127, שם הוא זקוק לצורה מרובת הבתים של base-128 ש-builds קודמים לא החילו. אף OID של SHA-2 לא מושפע — 2.16 מתקפל ל-96 — אבל OID פגום עכשיו מעלה את השגיאה המשלה של הספק במקום EConvertError

למה בקשה שנוסתה שוב לא מניבה חתימה שנייה?

THPDFCSCSignatureProvider גורם לכל קריאה שניתנת לניסיון חוזר לשאת מפתח idempotency דטרמיניסטי ומקבץ ל-cache תוצאות שהושלמו, כך שניסיון חוזר אחרי תשובה שאבדה מחזיר את החתימות המקוריות במקום לבקש מה-HSM חדשות. המפתח הוא csc- ואחריו ה-SHA-256 ההקסדצימלי של מזהה הפעולה והשלב, והשלב מטמיע את טביעת האצבע של האצווה גם עבור ה-authorize וגם עבור ה-signHash. לחשב hash במקום לחתוך חשוב: שני מזהי פעולה ארוכים שחולקים קידומת היו מתנגשים תחת חיתוך, בזמן שמפתח באורך קבוע הממוען-תוכן נשאר ייחודי ויציב בין ניסיונות

מדיניות הניסיונות החוזרים בנתיב הבקשה המשותף מצומצמת במכוון:

  • HTTP 401 כופה בדיוק רענון token אחד דרך callback של access-token, ואז הבקשה חוזרת פעם אחת כשcallback של access-token מוקצה; 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- המחושב-hash ממזהה הפעולה והשלב, HTTP 401 כופה בדיוק רענון token אחד, תשובות 4xx אחרות מסתיימות ב-spsProviderError, ו-408, 429, 5xx או כשל תעבורה זמני חוזרים עד RetryLimit של 2 בהמתנה ל-Retry-After או backoff אקספוננציאלי עם תקרה של 5,000 ms
אצוות שהושלמו מקובצות ל-cache לפי מזהה פעולה, credential וטביעת אצבע, ובמצב אסינכרוני ה-responseID השמור מאפשר לקריאה חוזרת לחדש את ה-polling במקום לשלוח מחדש את ה-hash

חתימה אסינכרונית (operationMode "A", ברירת המחדל) מוסיפה שומר אחד נוסף: ה-responseID נשמר לפני שנשאלים signatures/signPolling, כך שקריאה חוזרת עם אותו מזהה פעולה מחדשת את ה-polling במקום לשלוח מחדש. אצוות שהושלמו יושבות ב-cache הממופתח לפי מזהה פעולה, credential וטביעת אצבע, מוגבל ב-MaxOperationCacheEntries (128) ומוחזר כעותקים עמוקים. ה-cache הזה חי במופע הספק ולא שורד הפעלה מחדש. מפתח ה-idempotency כן שורד, כי הוא נגזר ולא אקראי, ולכן תהליך שהופעל מחדש שמשתמש במזהה הפעולה שלו שולח את אותו מפתח — האם השירות מדיופל על זה היא ההבטחה של השירות, לא של HotPDF

איך מכניסים חתימת CSC לתוך PDF?

מעבירים את הספק אל HPDFCMSSignPDFStreamWithProvider יחד עם תעודת ה-end-entity מ-GetCertificateChain; HotPDF בונה את ה-CMS SignedData והספק חותם את ה-digest של התכונות החתומות. קובץ ה-PDF הקלט זקוק ל-placeholder של ה-/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 מפרט את תעודת ה-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;

כיוון ה-placeholder של ה-/Contents עובר דרך EstimateSignatureSize, שמחזיר EstimatedSignatureBytes כשמגדירים אותו ואחרת את גודל ה-modulus של ה-RSA מאורך המפתח של ה-credential. עבור ECDSA מגדירים את EstimatedSignatureBytes בעצמכם, או שההערכה מדווח spsUnsupported. וריאנט החתימה בגודל אוטומטי חותם מחדש כש-placeholder מתגלה קטן מדי, והוא עושה זאת רק עבור ספקים שמכריזים על spcSafeSignRetry — מה שה-THPDFCSCSignatureProvider עושה רק בזמן שה-EnableIdempotency דלוק. עבור תהליכי PAdES-B-T, ה-TimestampDigest מבקש token חותמת-זמן מאותו שירות דרך signatures/timestamp, מוגבל ב-MaxTimestampBytes (1 MB)

מה ספק ה-CSC לא עושה?

הוא לא חותם הודעות, רק digest-ים. Ed25519 ו-Ed448 במצב pure מוסרים לספק את כל הודעת התכונות החתומות (sikMessage), ומאמת האצוות דוחה זאת כפגום, כי signHash מוגדר כמבוסס-hash. יחידת הספק מתקמפלת תחת Free Pascal עם סוגי פונקציה פשוטים במקום מתודות אנונימיות, אבל בוני ה-CMS המונעים-ספק מעלים חריגה תחת FPC כיום, ולכן הטמעת חתימת CSC ב-PDF היא נתיב דלפי

הוא גם לא מחליט מדיניות. ה-CredentialInfo מדווח את מצב המפתח, מצב התעודה, מצב האימות, רמת ה-SCAL ומגבלת ה-multisign, אבל הספק לא יסרב בעצמו למפתח מנוטרל או ל-credential SCAL1 — בדקו את אלה לפני שאתם מציגים לחותם את בקשת ה-OTP. ומופע ספק אחד חותם אצווה אחת בכל רגע: ה-SignHashBatch מסודר בפנים כך ששני תהליכונים לא יתחרו על SAD אחד, מה שאומר שהתפוקה באה מאצוות, לא משיתוף ספק בין תהליכוני עבודה. האם החתימה המתקבלת מאושרת תלויה בשירות האמון וב-credential שלו, לא בספרייה שהובילה את ה-hash לשם

ספק ה-CSC, בוני ה-CMS וה-PAdES והספקים המקומיים ושל PKCS#11 מגיעים כולם ברכיב HotPDF ל-Delphi