Una petición signatures/signHash a un servicio de firma remoto puede agotar su tiempo después de que el HSM ya produjo la firma, y el cliente no puede saber en qué lado de esa línea aterrizó. Reintente a ciegas y una aprobación puede producir dos firmas sobre el mismo contrato. Se niegue a reintentar y un lote nocturno muere por un solo 503. Firme cuarenta facturas de un hash en un tiempo y el firmante teclea cuarenta contraseñas de un solo uso
Cualquier integración de Cloud Signature Consortium (CSC) tiene que resolver ambos. Los prestadores de servicios de confianza que emiten claves cualificadas reparten cada vez más un endpoint de API CSC y un cliente OAuth en lugar de un archivo PFX o un token USB, así que no hay nada que importar a un almacén local a la manera en que funciona firmar con el almacén de certificados de Windows vía CNG y CAPI. La clave se queda en el HSM del prestador, y cada firma es un viaje de ida y vuelta por HTTPS
¿Por qué HotPDF deja el HTTP a su aplicación?
HotPDF firma a través de un servicio CSC remoto con THPDFCSCSignatureProvider, un provider de firma que ejecuta el protocolo CSC v2 (credential info, autorización, signatures/signHash, polling) mientras su código Delphi es dueño del transporte HTTP y del token OAuth. Los proxies, el TLS pinning, los certificados de cliente, las cajas fuertes de tokens y el logging de peticiones viven todos en la capa HTTP, y difieren en cada despliegue
Para cada petición el provider construye un THPDFCSCTransportRequest y llama a su función THPDFCSCTransport. El registro lleva la URL completa, una cabecera Authorization bearer lista, el Body JSON, una IdempotencyKey, el OperationIdentifier y el número de Attempt. Su función rellena un THPDFCSCTransportResponse con StatusCode, Body y RetryAfterMS, y luego devuelve ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure o ctsCancelled
Un transporte construido sobre el THTTPClient de la RTL necesita unas cuarenta líneas. El body sale como bytes en crudo, así que el JSON jamás pasa por una conversión de code page:
uses
System.SysUtils, System.Classes, 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, Reply: TMemoryStream;
Headers: TNetHeaders;
HttpResp: IHTTPResponse;
begin
Response := Default(THPDFCSCTransportResponse);
Body := TMemoryStream.Create;
Reply := TMemoryStream.Create;
try
if Request.Body <> '' then
Body.WriteBuffer(Request.Body[1], Length(Request.Body));
Body.Position := 0;
Headers := [TNameValuePair.Create('Authorization', string(Request.Authorization)),
TNameValuePair.Create('Content-Type', string(Request.ContentType))];
if Request.IdempotencyKey <> '' then // nombre de cabecera tal como lo documenta su servicio
Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
string(Request.IdempotencyKey))];
try
HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
except
on ENetHTTPClientException do
Exit(ctsTemporaryFailure); // problemas de DNS, TLS o socket: reintentable
end;
Response.StatusCode := HttpResp.StatusCode; // pase 429 y 503 sin tocar
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
// Retry-After en segundos; un valor HTTP-date cae a 0 aquí
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
La única regla que hay que memorizar: devuelva ctsSuccess siempre que un servidor haya contestado, aunque sea con un 503. El provider clasifica los códigos de estado por su cuenta, y un transporte que mapee un 429 a ctsPermanentFailure apaga la lógica de reintentos descrita abajo. Los bodies mayores que MaxResponseBytes (2 MB por defecto) se rechazan con spsMalformed devuelva lo que devuelva el transporte
El constructor es estricto en la otra dirección. Lanza EHPDFCSCSignatureProviderError cuando falta el transporte, ServiceBaseURL o CredentialID está vacío, no se suministra ni un AccessToken ni un callback de token, un presupuesto está fuera de rango, o la URL no es HTTPS. El http:// llano pasa solo con AllowInsecureHTTP activado, y ese flag pertenece a un banco de pruebas apuntado a un servicio mock local
¿Qué es el SAD y por qué es de un solo uso?
THPDFCSCSignatureProvider trata la Signature Activation Data (SAD) como de un solo uso: se limpia del estado del provider en el momento en que signatures/signHash se acepta, incluso cuando la firma en sí llega después mediante polling asíncrono. El SAD es la prueba del servicio de que el firmante aprobó estos hashes concretos. Uno que merodea en memoria es una autorización esperando gastarse en el documento equivocado
Con los valores por defecto de THPDFCSCOptions.Default (RequireSAD y AutoAuthorize ambos True), el provider carga credentials/info una vez, pide a su THPDFCSCAuthenticationCallback los valores de authData que el bloque auth del credential exija, normalmente un OTP o un PIN, y envía credentials/authorize. Una respuesta 200 trae el SAD directamente. Un 202 trae un handle, que el provider sondea vía credentials/authorizeCheck hasta MaxPollAttempts (60) veces a PollIntervalMS (250 ms) salvo que el servicio envíe Retry-After. El callback puede devolver como máximo 32 valores, cada uno con un ID no vacío de hasta 256 bytes y un valor de hasta 4.096 bytes
La limpieza ocurre después de la aceptación. Si la red se cae antes de que signHash se acepte, un SAD obtenido automáticamente se queda en su sitio, todavía atado al mismo lote, así que el reintento sale sin un segundo diálogo de OTP. El firmante aprobó una vez; el provider gasta esa aprobación una vez
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, modo asíncrono, 2 reintentos
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
// TokenVault es su propio cliente OAuth; ForceRefresh es True tras un 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 // su propia UI
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
El callback de token se dispara cuando el token cacheado falta o está a menos de TokenRefreshSkewSeconds (30) del vencimiento que usted reportó; un ExpiresAtUTC de cero significa que el token se reutiliza hasta que un 401 fuerza un refresco
Un SAD que usted pasa por su cuenta vía Options.SAD se comporta distinto, y a propósito. Nada le dice al provider qué hashes cubre ese SAD, así que un SAD preajustado se usa solo para una petición de un solo hash. Para un lote multi-hash con AutoAuthorize activado descarta el valor preajustado y autoriza el lote; con AutoAuthorize apagado falla con "CSC SAD is not pinned to the requested hash batch" en lugar de adivinar. Los servicios que autorizan solo por el scope de OAuth y jamás emiten un SAD funcionan con RequireSAD puesto a False, en cuyo caso el miembro SAD se queda fuera del body de signHash
Firmar cuarenta facturas con un solo diálogo de OTP
SignHashBatch envía un credentials/authorize y un signatures/signHash para hasta MaxBatchSignatures (64) digests, y construye ambos cuerpos desde el mismo array, así que numSignatures, el orden de hashes y hashAlgorithmOID concuerdan entre las dos llamadas. Loopee el método Sign de un solo hash cuarenta veces y consigue cuarenta autorizaciones. Envíe un authorize y un signHash que no concuerden y el servicio puede gastar el SAD contra el lote equivocado
La validación corre antes de cualquier tráfico de red. Cada petición tiene que ser un digest (sikDigest) de 1 a 1.024 bytes con un OID de digest, y todas las peticiones deben compartir un algoritmo de firma, un OID de digest y, para RSASSA-PSS, una longitud de salt. Un lote multi-hash también carga credentials/info y devuelve spsUnsupported cuando el valor multisign del credential es menor que el tamaño del lote. La autorización se ata entonces a una huella SHA-256 sensible al orden de la lista de peticiones, así que intercambiar dos hashes hace un lote distinto que necesita una aprobación fresca
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
OpId: AnsiString;
I: Integer;
begin
SetLength(Requests, Length(Digests)); // Digests: valores SHA-256 que usted calculó
for I := 0 to High(Digests) do
begin
Requests[I] := Default(THPDFSignatureProviderRequest);
Requests[I].Algorithm := hsaRSAPKCS1v15; // signAlgo se deriva cuando AlgorithmOID está vacío
Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
Requests[I].InputKind := sikDigest;
Requests[I].Input := Digests[I];
end;
// Derive el ID de su propio registro de ejecución, jamás de un GUID fresco por llamada
OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
Status := Provider.SignHashBatch(Requests, OpId, Signatures);
if Status = spsProviderError then
// Mismo OpId: un lote completado vuelve de la caché, un job asíncrono
// pendiente retoma el sondeo, una petición no enviada reutiliza la misma clave
Status := Provider.SignHashBatch(Requests, OpId, Signatures);
if Status <> spsValid then
raise Exception.CreateFmt('CSC batch failed (HTTP %d): %s',
[Provider.LastHTTPStatus, Provider.LastError]);
// Signatures[I] pertenece a Digests[I]; el conteo se cotejó contra la petición
end;
Para RSASSA-PSS el provider añade signAlgoParams, una estructura DER en base64 RSASSA-PSS-params que lleva el algoritmo de hash, MGF1 y la longitud de salt. X.690 §8.19.4 pliega los dos primeros arcos de un OID en un valor (40 × primero + segundo), y bajo la raíz 2 un segundo arco por encima de 39 empuja ese valor más allá de 127, donde necesita la forma multibyte en base 128. Ningún OID SHA-2 ejercita ese camino, ya que 2.16 se pliega a 96, así que un codificador puede pasar todos los tests SHA-2 y seguir estando mal. El provider escribe la forma multibyte y reporta un OID malformado como su propio EHPDFCSCSignatureProviderError en lugar de un EConvertError
¿Por qué una petición reintentada no produce una segunda firma?
THPDFCSCSignatureProvider da a cada llamada reintentable una clave de idempotencia determinista y cachea los resultados completados, así que un reintento tras una respuesta perdida devuelve las firmas originales en lugar de pedirle nuevas al HSM. La clave es csc- seguido del SHA-256 hex del identificador de operación y la fase del protocolo, y para la autorización y signHash la fase incluye la huella del lote. Hashear en lugar de truncar mantiene la clave de longitud fija y evita que dos IDs de operación largos con prefijo compartido colisionen
La política de reintentos en el camino compartido de peticiones es estrecha a propósito:
- Un HTTP 401 fuerza exactamente un refresco de token a través del callback de access token, y la petición se repite una vez; un segundo 401 es definitivo, y sin callback de token lo es el primero
- El resto de respuestas 4xx y
ctsPermanentFailureterminan la llamada conspsProviderError, y elerror_description(oerror) del servicio aterriza enLastError - 408, 429, 5xx y
ctsTemporaryFailurese reintentan hastaRetryLimit(2 por defecto), esperando aRetry-Aftero aRetryBaseDelayMS(100 ms) doblado en cada intento, con tope enMaxRetryAfterMS(5.000 ms) - Las esperas corren en trozos de 25 ms que comprueban
Cancel, así que un usuario que aborta no se traga un back-off de cinco segundos signHashsolo se reintenta conEnableIdempotencyactivado; apáguelo y un timeout tras el envío es definitivo, porque nadie puede saber si la clave ya se usó
La firma asíncrona (operationMode "A", el valor por defecto, con un validity_period de AsyncValidityPeriodMS, 60.000 ms) añade una guardia más. El responseID se guarda antes de que el provider empiece a sondear signatures/signPolling, así que una llamada repetida con el mismo identificador de operación retoma el job pendiente en lugar de reenviar el hash. Los lotes completados se cachean por instancia de provider, hasta MaxOperationCacheEntries (128), y esa caché se esfuma tras un reinicio. La clave de idempotencia sobrevive, porque se deriva en lugar de ser aleatoria: un proceso reiniciado que reutiliza su identificador de operación envía la misma clave, y si el servicio deduplica sobre ella es parte del contrato del servicio
Todo esto pende del identificador de operación, y el parámetro es fácil de dejar vacío. Pase '' a SignHashBatch y el provider recae en el OperationIdentifier de las peticiones, y luego en un GUID nuevo. Un GUID nuevo por llamada significa que su propio bucle de reintentos arranca una operación nueva cada vez: nueva ranura de caché, nueva clave de idempotencia, y posiblemente un segundo diálogo de OTP. También significa que Provider.Cancel no tiene nada que apuntar, porque su código jamás vio el GUID. Derive el identificador de algo que usted ya persiste, como el número de lote o el ID de documento, y mantenga EnableIdempotency en su valor por defecto de True. Si un servicio rechaza la cabecera, suértela en el transporte en lugar de apagar la opción
¿Cómo se mete una firma CSC en un PDF?
Pase el provider a HPDFCMSSignPDFStreamWithProvider junto con el certificado de entidad final de GetCertificateChain; HotPDF construye el CMS SignedData y el provider firma el digest de los signed attributes. El PDF de entrada necesita el placeholder de /ByteRange y /Contents que escribe THPDFPage.AddSignedSignatureField, exactamente como en el flujo de firma PAdES en HotPDF, y el modelo de provider es el mismo que cubre los providers de firma conectables de HotPDF para ML-DSA y EdDSA
var
Chain: THPDFCSCCertificateChain;
SignOpts: THPDFCMSSignOptions;
Src, Dst: TFileStream;
begin
if Provider.RefreshCredentialInfo <> spsValid then
raise Exception.Create(Provider.LastError);
Chain := Provider.GetCertificateChain; // CSC lista primero el certificado de entidad final
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;
Para dimensionar el placeholder, EstimateSignatureSize devuelve EstimatedSignatureBytes cuando usted lo fija, y en caso contrario el tamaño del modulus RSA derivado de la longitud de la clave en credentials/info. Para claves ECDSA responde spsUnsupported y el estimador del placeholder recae en el certificado, así que fijar EstimatedSignatureBytes usted mismo da un resultado previsible. HPDFCMSSignPDFStreamWithProviderAutoSize reconstruye y refirma cuando un placeholder resulta demasiado pequeño, y solo lo hace con providers que anuncian spcSafeSignRetry. THPDFCSCSignatureProvider lo anuncia mientras EnableIdempotency está activado, que es una razón más para dejar ese valor por defecto en paz
Para PAdES-B-T, TimestampDigest pide al mismo servicio un token RFC 3161 vía signatures/timestamp, con un nonce opcional y un tope de tamaño de MaxTimestampBytes (1 MB)
Decisiones de política que se quedan en su código
El provider firma digests. Ed25519 y Ed448 en modo puro le entregan a un provider de firma el mensaje entero de signed attributes (sikMessage), y el validador de lotes lo rechaza como malformado, porque signHash es por definición basado en hash. Bajo Free Pascal la unidad del provider compila con tipos de función llanos en lugar de métodos anónimos, así que SignHashBatch y TimestampDigest son invocables allí; incrustar el resultado en un PDF vía HPDFCMSSignPDFStreamWithProvider es un camino Delphi
El provider también deja la política en sus manos. CredentialInfo reporta KeyStatus, CertificateStatus, AuthorizationMode, SCAL y MultiSignatures, pero una clave deshabilitada o un credential SCAL1 es decisión suya de rechazar. Compruebe esos campos justo después de RefreshCredentialInfo y antes de que el firmante vea jamás un diálogo de OTP; un rechazo del servicio después de que el firmante tecleó un código es una experiencia peor que un error claro de entrada
Una instancia de provider corre un lote por vez, porque SignHashBatch, RefreshCredentialInfo y TimestampDigest comparten un lock para que dos hilos no puedan competir por un SAD. El throughput sale de lotes más grandes, hasta el límite multisign del credential. Que la firma resultante cuente como cualificada lo deciden el servicio de confianza y el credential que emitió
El provider CSC llega junto a los builders CMS y PAdES y los providers de firma locales y PKCS#11 en el componente Delphi PDF de HotPDF, así que la firma remota, por token y por archivo alimentan todos las mismas llamadas de firma PDF