Bài viết kỹ thuật

Ký từ xa CSC với HotPDF: chữ ký PDF đám mây trong Delphi

HotPDF ký tài liệu PDF bằng một khóa riêng do một dịch vụ Cloud Signature Consortium (CSC) từ xa nắm giữ, thông qua THPDFCSCSignatureProvider, một signature provider điều khiển CSC API — credential info, authorization, signatures/signHash và polling — trong khi ứng dụng Delphi của bạn cung cấp HTTP transport và OAuth access token. Khóa không bao giờ rời HSM của dịch vụ

Đó ngày càng là cách duy nhất để có nổi một khóa ký qualified. Các nhà cung cấp dịch vụ tin cậy phát cho bạn một endpoint CSC và một OAuth client, chứ không phải một tệp PFX hay một USB token, nên chẳng có gì để nạp vào certificate store cục bộ như cách ký qua cert store Windows bằng CNG và CAPI đã làm. Bản tích hợp ngây thơ sẽ gục theo những cách đoán trước được: một lệnh gọi signHash hết giờ và lượt retry ký trùng hợp đồng hai lần, hay một lô bốn mươi hóa đơn làm nổ ra bốn mươi mật khẩu dùng một lần vì mỗi hash được authorize riêng. Phần lớn việc provider làm là phòng vệ trước đúng hai thất bại ấy

Vì sao HotPDF để phần HTTP cho ứng dụng của bạn?

Vì transport chính là chỗ mà mọi bản triển khai khác nhau. Proxy, TLS pinning, client certificate, kho OAuth của công ty và chính sách log đều sống ở tầng HTTP, nên THPDFCSCSignatureProvider điều phối trạng thái giao thức và gọi một hàm THPDFCSCTransport cho mỗi request. Provider đưa cho bạn một THPDFCSCTransportRequest với Method (luôn là POST), URL đầy đủ dựng từ ServiceBaseURL cộng đường endpoint, một header bearer Authorization sẵn sàng, ContentType, Body JSON, một IdempotencyKey, số Attempt và MaxResponseBytes. Bạn đổ vào một THPDFCSCTransportResponse các giá trị StatusCode, Body và RetryAfterMS, rồi trả về một trong ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure hay ctsCancelled

Sơ đồ ranh giới transport CSC của HotPDF: THPDFCSCSignatureProvider điều phối giao thức và đưa code của bạn một THPDFCSCTransportRequest với method POST, URL đầy đủ, header bearer Authorization sẵn sàng, body JSON, một IdempotencyKey và số lần thử, còn bạn trả về StatusCode, Body, RetryAfterMS cùng một trong bốn giá trị trạng thái cts, trong khi khóa không bao giờ rời HSM
Provider tự phân loại status code, nên một transport biến một 503 đã có trả lời thành thất bại vĩnh viễn sẽ âm thầm vô hiệu hóa logic retry, trong khi proxy và chính sách TLS vẫn nằm trong code bạn sở hữu
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  // tên header theo đúng tài liệu dịch vụ của bạn ghi
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // rắc rối socket hay DNS: retry được
        end;
        Response.StatusCode := HttpResp.StatusCode;   // báo 503 nguyên vẹn, đừng tự phân loại
        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;

Một luật đáng thuộc lòng: trả về ctsSuccess bất cứ khi nào server thực sự có trả lời, kể cả 503. Provider tự phân loại status code, và một transport biến 429 thành ctsPermanentFailure sẽ âm thầm vô hiệu hóa logic retry nói dưới đây. Constructor nghiêm ngặt theo hướng ngược lại — nó ném EHPDFCSCSignatureProviderError khi thiếu transport, CredentialID rỗng, chẳng có AccessToken nào lẫn callback token nào được cung cấp, một budget vượt khoảng cho phép, hay ServiceBaseURL không phải HTTPS. http:// thuần chỉ được chấp nhận với AllowInsecureHTTP, thứ chỉ hợp với một rig kiểm thử chứ không nơi nào khác

SAD là gì, và vì sao HotPDF vứt nó sau một lần dùng?

THPDFCSCSignatureProvider coi Signature Activation Data (SAD) là dùng một lần: nó bị xóa khỏi trạng thái provider ngay khi signatures/signHash được chấp nhận, kể cả khi chữ ký thực sự đến sau qua polling bất đồng bộ. SAD là bằng chứng của dịch vụ rằng người ký đã duyệt chính những hash này, và một SAD còn vương trong bộ nhớ là một ủy quyền đang chờ bị tiêu vào nhầm tài liệu

Với các mặc định của THPDFCSCOptions.Default — cả RequireSAD lẫn AutoAuthorize đều True — provider nạp credentials/info một lần, hỏi THPDFCSCAuthenticationCallback của bạn các giá trị authData (một OTP, một PIN, bất cứ gì khối auth của credential đòi hỏi), rồi gửi credentials/authorize. Một 200 mang SAD thẳng; một 202 mang một handle được poll qua credentials/authorizeCheck tối đa MaxPollAttempts (60) lần mỗi PollIntervalMS (250 ms). Callback có thể trả về tối đa 32 giá trị, mỗi giá trị có một ID không rỗng tới 256 byte và một giá trị tới 4.096 byte. Nếu mạng đứt trước khi signHash được chấp nhận, một SAD thu được tự động được giữ lại để cùng lô có thể retry mà chẳng phải hỏi lại người ký

Sơ đồ vòng đời SAD của HotPDF: với RequireSAD và AutoAuthorize, provider nạp credentials/info một lần, hỏi callback xác thực các giá trị OTP hay PIN, gửi credentials/authorize, poll credentials/authorizeCheck tối đa 60 lần mỗi 250 ms khi đáp án là 202, và xóa Signature Activation Data ngay khi signatures/signHash được chấp nhận, giữ một SAD đã thu khi mạng đứt trước lúc chấp nhận
Một SAD còn vương trong bộ nhớ là một ủy quyền đang chờ bị tiêu vào nhầm tài liệu, còn một SAD cài sẵn truyền qua options chỉ được dùng cho request một hash duy nhất
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, chế độ async, 2 lần 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 của bạn; ForceRefresh là True sau khi dịch vụ trả 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 của bạn
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Một SAD bạn tự truyền vào qua Options.SAD hành xử khác đi, và là chủ đích. HotPDF không thể biết nó được cấp cho những hash nào, nên provider chỉ dùng một SAD cài sẵn cho request một hash. Với một lô mà AutoAuthorize bị tắt, provider thất bại với "CSC SAD is not pinned to the requested hash batch" thay vì đoán

SignHashBatch ký nhiều tài liệu với một ủy quyền thế nào?

SignHashBatch gửi một credentials/authorize và một signatures/signHash cho tới MaxBatchSignatures (64) digest, và dựng cả hai body từ cùng một mảng để numSignatures, thứ tự hashes và hashAlgorithmOID giống hệt nhau trong hai lệnh gọi. Sự khớp đó chính là điều mô hình multisign CSC đòi hỏi. Lặp phương thức Sign một-hash bốn mươi lần là bạn có bốn mươi ủy quyền; gửi một authorize và một signHash không khớp nhau thì dịch vụ có thể tiêu SAD vào sai lô

Trước bất kỳ giao tiếp mạng nào, provider xác thực lô. Mỗi request phải là một digest (sikDigest) từ 1 tới 1.024 byte với một OID digest, và mọi request phải chia sẻ một OID thuật toán ký, một OID digest và, với RSASSA-PSS, một độ dài salt. Một lô nhiều hash còn nạp credentials/info và trả về spsUnsupported khi giá trị multisign của credential nhỏ hơn lô. SAD sau đó được ghim vào một fingerprint lô — một SHA-256 trên một nhãn phiên bản, số lượng và, cho từng request, OID thuật toán, OID digest, thuật toán, độ dài salt cùng byte digest, mỗi cái có tiền tố độ dài. Tráo hai hash cho nhau là bạn có một lô khác cần một ủy quyền mới

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: các giá trị SHA-256 bạn đã tính
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo suy ra khi AlgorithmOID rỗng
    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] thuộc về Digests[I]; số lượng đã được đối chiếu với request
end;

Với RSASSA-PSS, provider còn gửi signAlgoParams, một cấu trúc DER RSASSA-PSS-params mã hóa base64 chứa thuật toán hash, MGF1 và độ dài salt. Dựng nó nghĩa là mã hóa OID, và bản 2.748.5 đã vá một góc khuất của việc đó: X.690 §8.19.4 gập hai arc đầu tiên thành một giá trị (40 × arc đầu + arc hai), và dưới gốc 2, một arc thứ hai trên 39 đẩy giá trị đó vượt 127, nơi nó cần dạng đa byte base-128 mà các bản build trước chưa áp. Chẳng OID SHA-2 nào bị ảnh hưởng — 2.16 gập thành 96 — nhưng một OID dị dạng giờ ném lỗi riêng của provider thay vì một EConvertError

Vì sao một request được retry không sinh ra chữ ký thứ hai?

THPDFCSCSignatureProvider buộc mọi lệnh gọi retry-được mang một idempotency key tất định và cache các kết quả đã xong, nên một lượt retry sau khi mất phản hồi trả về các chữ ký gốc thay vì xin HSM chữ ký mới. Key là csc- theo sau bằng hex SHA-256 của định danh thao tác và pha, và pha nhúng fingerprint lô cho cả authorization lẫn signHash. Hash thay vì cắt ngắn mới là chuyện đáng nói: hai operation ID dài dùng chung một tiền tố sẽ đụng nhau dưới phép cắt ngắn, còn một key đánh địa chỉ theo nội dung, độ dài cố định thì độc nhất và ổn định qua các lần thử

Chính sách retry trong đường request dùng chung được siết hẹp một cách chủ đích:

  • HTTP 401 ép đúng một lần refresh token qua callback access-token, rồi request được lặp lại một lần khi có callback access-token; một 401 thứ hai là chung cuộc
  • Các phản hồi 4xx khác và ctsPermanentFailure chấm dứt lệnh gọi với spsProviderError, và error_description của dịch vụ rơi vào LastError
  • 408, 429, 5xx và ctsTemporaryFailure được retry tối đa RetryLimit lần (mặc định 2), chờ Retry-After hay RetryBaseDelayMS × 2attempt (nền 100 ms), trần ở MaxRetryAfterMS (5.000 ms)
  • Thời gian chờ chạy theo từng lát 25 ms có kiểm tra Cancel, nên người dùng hủy không phải ngồi đợi một back-off năm giây
  • signHash chỉ được retry khi EnableIdempotency bật; tắt nó đi thì một timeout sau khi gửi là chung cuộc, vì chẳng ai biết được khóa đã được dùng hay chưa
Sơ đồ chính sách retry của HotPDF: mọi lệnh gọi retry-được mang một idempotency key csc- tất định hash từ định danh thao tác và pha, HTTP 401 ép đúng một lần refresh token, các đáp án 4xx khác kết thúc với spsProviderError, còn 408, 429, 5xx hay một lỗi transport tạm thời retry tới RetryLimit bằng 2 trong lúc chờ Retry-After hay backoff nhân đôi trần 5.000 ms
Các lô đã xong được cache theo định danh thao tác, credential và fingerprint, còn ở chế độ bất đồng bộ, responseID đã lưu cho phép một lệnh gọi lặp lại tiếp tục polling thay vì gửi lại hash

Ký bất đồng bộ (operationMode "A", mặc định) thêm một lớp phòng nữa: responseID được lưu trước khi poll signatures/signPolling, nên một lệnh gọi lặp lại với cùng định danh thao tác tiếp tục polling thay vì gửi lại. Các lô đã xong nằm trong một cache khóa theo định danh thao tác, credential và fingerprint, trần bởi MaxOperationCacheEntries (128) và được trả về dưới dạng bản copy sâu. Cache đó sống trong instance provider và không sống sót qua một lần khởi động lại. Idempotency key thì có, vì nó được suy ra chứ không ngẫu nhiên, nên một tiến trình khởi động lại mà tái dùng định danh thao tác sẽ gửi đúng key cũ — dịch vụ có loại bỏ trùng lặp theo nó hay không là lời hứa của dịch vụ, không phải của HotPDF

Đưa một chữ ký CSC vào một PDF thế nào?

Truyền provider cho HPDFCMSSignPDFStreamWithProvider cùng với certificate end-entity lấy từ GetCertificateChain; HotPDF dựng CMS SignedData và provider ký digest của các signed attribute. PDF đầu vào cần placeholder /ByteRange và /Contents mà THPDFPage.AddSignedSignatureField ghi ra, hệt như trong quy trình ký PAdES trong HotPDF, còn mô hình provider là mô hình đã được bàn trong các signature provider cắm được của HotPDF cho ML-DSA và EdDSA

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC liệt kê certificate end-entity trước tiên
  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;

Đo đạc placeholder /Contents đi qua EstimateSignatureSize, hàm trả về EstimatedSignatureBytes khi bạn đặt nó, còn không thì kích thước modulus RSA suy ra từ độ dài khóa của credential. Với ECDSA, hãy tự đặt EstimatedSignatureBytes, nếu không phép ước lượng báo spsUnsupported. Biến thể ký tự động cỡ lại sẽ ký lại khi một placeholder hóa ra quá nhỏ, và nó chỉ làm vậy với các provider quảng cáo spcSafeSignRetry — điều THPDFCSCSignatureProvider chỉ công nhận khi EnableIdempotency bật. Với quy trình PAdES-B-T, TimestampDigest xin một timestamp token từ chính dịch vụ đó qua signatures/timestamp, trần ở MaxTimestampBytes (1 MB)

CSC provider không làm gì?

Nó không ký thông điệp, chỉ ký digest. Ed25519 và Ed448 ở chế độ pure đưa cho provider cả thông điệp signed-attributes (sikMessage), và bộ kiểm tra lô từ chối nó như thể dị dạng, vì signHash theo định nghĩa là dựa trên hash. Unit provider compile được dưới Free Pascal với các kiểu hàm thường thay cho anonymous method, nhưng các bộ dựng CMS chạy theo provider thì ném lỗi trên FPC hiện nay, nên việc nhúng một chữ ký CSC vào PDF là đường của Delphi

Nó cũng không quyết định chính sách. CredentialInfo báo trạng thái khóa, trạng thái certificate, chế độ authorization, cấp SCAL và giới hạn multisign, nhưng provider sẽ không tự từ chối một khóa bị vô hiệu hay một credential SCAL1 — hãy kiểm tra những thứ đó trước khi đưa người ký thấy ô nhập OTP. Và một instance provider ký một lô tại một thời điểm: SignHashBatch được tuần tự hóa nội bộ nên hai thread không thể tranh nhau một SAD, nghĩa là throughput đến từ việc gom lô, chứ không phải từ việc chia sẻ provider giữa các worker thread. Chữ ký thu được có qualified hay không phụ thuộc vào dịch vụ tin cậy và credential của nó, chứ không phải vào thư viện đã mang hash tới đó

CSC provider, các bộ dựng CMS và PAdES, cùng các provider cục bộ và PKCS#11 đều có mặt trong HotPDF Delphi PDF component