Una solicitud signatures/signHash a un servicio de firma remota puede expirar cuando el HSM ya produjo la firma, y el cliente no puede saber en cuál lado de esa línea cayó. 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 un hash por llamada y el firmante teclea cuarenta contraseñas de un solo uso
Cualquier integración con el Cloud Signature Consortium (CSC) tiene que resolver ambos. Los proveedores de servicios de confianza que emiten claves cualificadas cada vez más entregan 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 como funciona la firma por el almacén de certificados de Windows vía CNG y CAPI. La clave se queda en el HSM del proveedor, y cada firma es un viaje de ida y vuelta por HTTPS
¿Por qué HotPDF deja el HTTP en manos de su aplicación?
HotPDF firma vía un servicio CSC remoto con THPDFCSCSignatureProvider, un signature provider que corre 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, los token vaults y el registro de solicitudes viven todos en la capa HTTP, y difieren en cada despliegue
Para cada solicitud el provider arma un THPDFCSCTransportRequest y llama a su función THPDFCSCTransport. El record carga el URL completo, un header bearer Authorization listo, el Body JSON, una IdempotencyKey, el OperationIdentifier y el número de Attempt. Su función llena un THPDFCSCTransportResponse con StatusCode, Body y RetryAfterMS, y luego devuelve ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure o ctsCancelled
Un transporte armado sobre el THTTPClient del RTL necesita unas cuarenta líneas. El body sale como bytes crudos, así que el JSON nunca pasa por una conversión de página de códigos:
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 del header como lo documente 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: reintenable
end;
Response.StatusCode := HttpResp.StatusCode; // pase 429 y 503 intactos
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 memorizar: devuelva ctsSuccess siempre que un server contestó, aunque sea un 503. El provider clasifica los códigos de estado por su cuenta, y un transporte que mapea un 429 a ctsPermanentFailure apaga la lógica de reintentos descrita abajo. Los bodies mayores que MaxResponseBytes (2 MB por default) 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 el URL no es HTTPS. El http:// plano pasa solo con AllowInsecureHTTP asignado, y ese flag pertenece a un rig 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 es aceptado, incluso cuando la firma misma llega después vía polling asíncrono. El SAD es la prueba del servicio de que el firmante aprobó estos hashes en particular. Uno que queda viviendo en memoria es una autorización esperando gastarse en el documento equivocado
Con los defaults de THPDFCSCOptions.Default (RequireSAD y AutoAuthorize ambos en True), el provider carga credentials/info una vez, le pide a su THPDFCSCAuthenticationCallback los valores authData que exige el bloque auth de la credencial, normalmente un OTP o un PIN, y envía credentials/authorize. Una respuesta 200 trae el SAD directo. Una 202 trae un handle, que el provider sondea vía credentials/authorizeCheck hasta MaxPollAttempts (60) veces a PollIntervalMS (250 ms) salvo que el servicio mande Retry-After. El callback puede devolver cuando mucho 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 sea aceptado, un SAD obtenido automáticamente permanece en su lugar, todavía atado al mismo lote, así que el reintento sale sin un segundo prompt 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 async, 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 en caché falta o está a menos de TokenRefreshSkewSeconds (30) del expiry 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 preestablecido se usa solo para una solicitud de un solo hash. Para un lote multi-hash con AutoAuthorize encendido descarta el valor preestablecido y autoriza el lote; con AutoAuthorize apagado falla con "CSC SAD is not pinned to the requested hash batch" en vez de adivinar. Los servicios que autorizan solo por el scope de OAuth y jamás emiten un SAD funcionan con RequireSAD en False, en cuyo caso el miembro SAD se deja fuera del body de signHash
Firmar cuarenta facturas con un solo prompt de OTP
SignHashBatch envía un credentials/authorize y un signatures/signHash para hasta MaxBatchSignatures (64) digests, y arma ambos bodies desde el mismo array, así que numSignatures, el orden de hashes y hashAlgorithmOID coinciden entre las dos llamadas. Loopee el método Sign de un solo hash cuarenta veces y obtiene cuarenta autorizaciones. Envíe un authorize y un signHash que discrepen y el servicio puede gastar el SAD contra el lote equivocado
La validación corre antes de cualquier tráfico de red. Cada solicitud tiene que ser un digest (sikDigest) de 1 a 1,024 bytes con un OID de digest, y todas las solicitudes deben compartir un algoritmo de firma, un OID de digest y, para RSASSA-PSS, una longitud de salt. Un lote multi-hash además carga credentials/info y devuelve spsUnsupported cuando el valor multisign de la credencial es menor que el tamaño del lote. La autorización queda entonces atada a un fingerprint SHA-256 sensible al orden de la lista de solicitudes, así que intercambiar dos hashes arma 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 computó
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 record de corrida, 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 regresa del caché, un job async pendiente
// retoma el polling, una solicitud 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] le corresponde a Digests[I]; el conteo se contrastó contra la solicitud
end;
Para RSASSA-PSS el provider agrega signAlgoParams, una estructura DER RSASSA-PSS-params en base64 que carga el algoritmo de hash, MGF1 y la longitud de salt. X.690 §8.19.4 pliega los primeros dos arcos de OID en un valor (40 × primero + segundo), y bajo la raíz 2 un segundo arco arriba de 39 lo empuja más allá de 127, donde necesita la forma multibyte base-128. Ningún OID SHA-2 ejercita ese camino, ya que 2.16 se pliega a 96, así que un encoder puede pasar todas las pruebas 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é un request reintentado no produce una segunda firma?
THPDFCSCSignatureProvider le da a cada llamada reintenable una idempotency key determinista y guarda en caché los resultados completados, así que un reintento tras una respuesta perdida devuelve las firmas originales en vez de pedirle al HSM unas nuevas. La clave es csc- seguida del hex SHA-256 del identificador de operación y la fase del protocolo, y para la autorización y signHash la fase incluye el fingerprint del lote. Hashear en vez de truncar mantiene la clave de longitud fija y evita que dos IDs de operación largos con un prefijo compartido colisionen
La política de reintentos en el camino compartido de solicitudes es angosta:
- Un HTTP 401 fuerza exactamente un refresco de token vía el callback de access token, y la solicitud se repite una vez; un segundo 401 es final, y sin callback de token el primero lo es
- Las demás respuestas 4xx y
ctsPermanentFailureterminan la llamada conspsProviderError, y elerror_description(oerror) del servicio cae enLastError - 408, 429, 5xx y
ctsTemporaryFailurese reintentan hastaRetryLimit(default 2), esperandoRetry-AfteroRetryBaseDelayMS(100 ms) doblado en cada intento, con tope enMaxRetryAfterMS(5,000 ms) - Las esperas corren en tajadas de 25 ms que revisan
Cancel, así que un usuario que aborta no se sienta a esperar un back-off de cinco segundos signHashse reintenta solo mientrasEnableIdempotencyestá encendido; apáguelo y un timeout después del envío es final, porque nadie puede saber si el hash ya estaba firmado
La firma asíncrona (operationMode "A", el default, con un validity_period de AsyncValidityPeriodMS, 60,000 ms) agrega una guarda 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 volver a enviar el hash. Los lotes completados se cachean por instancia de provider, hasta MaxOperationCacheEntries (128), y ese caché desaparece tras un reinicio. La idempotency key sobrevive, porque se deriva en vez 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 recurre al OperationIdentifier de las solicitudes, luego a un GUID nuevo. Un GUID nuevo por llamada significa que su propio bucle de reintentos arranca una operación nueva cada vez: slot de caché nuevo, idempotency key nueva, y posiblemente un segundo prompt de OTP. También significa que Provider.Cancel no tiene nada a qué apuntar, porque su código jamás vio el GUID. Derive el identificador de algo que ya persista, como el número de lote o el ID del documento, y deje EnableIdempotency en su default de True. Si un servicio rechaza el header, quítelo en el transporte en vez de apagar la opción
Embeber una firma CSC en un PDF
Pásele el provider a HPDFCMSSignPDFStreamWithProvider junto con el certificado end-entity de GetCertificateChain; HotPDF arma el CMS SignedData y el provider firma el digest de los atributos firmados. El PDF de entrada necesita el placeholder de /ByteRange y /Contents que THPDFPage.AddSignedSignatureField escribe, exactamente como en el flujo de firma PAdES en HotPDF, y el modelo de provider es el mismo detrás de los signature providers enchufables 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 el certificado end-entity primero
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 el dimensionado del placeholder, EstimateSignatureSize devuelve EstimatedSignatureBytes si usted lo asigna, y si no el tamaño del módulo RSA derivado de la longitud de clave en credentials/info. Para claves ECDSA responde spsUnsupported y el estimador del placeholder recurre al certificado, así que asignar EstimatedSignatureBytes usted mismo da un resultado predecible. HPDFCMSSignPDFStreamWithProviderAutoSize re-arma y re-firma cuando un placeholder resulta chico, y lo hace solo para providers que anuncian spcSafeSignRetry. THPDFCSCSignatureProvider lo anuncia mientras EnableIdempotency está encendido, que es una razón más para dejar ese default quieto
Para PAdES-B-T, TimestampDigest le 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 signature provider el mensaje completo de atributos firmados (sikMessage), y el validador de lotes lo rechaza como malformado, porque signHash es basado en hash por definición. Bajo Free Pascal la unit del provider compila con tipos de función planos en lugar de anonymous methods, así que SignHashBatch y TimestampDigest son llamables ahí; embeber 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 una credencial SCAL1 es su decisión rechazarla. Revise esos campos justo después de RefreshCredentialInfo y antes de que el firmante vea jamás un prompt de OTP; un rechazo del servicio después de que el firmante tecleó un código es peor experiencia que un error claro de entrada
Una instancia de provider corre un lote a la vez, porque SignHashBatch, RefreshCredentialInfo y TimestampDigest comparten un lock para que dos threads no se disputen un SAD. El throughput sale de lotes más grandes, hasta el límite multisign de la credencial. Si la firma resultante cuenta como cualificada lo deciden el servicio de confianza y la credencial que emitió
El provider CSC se distribuye junto con los constructores CMS y PAdES y los signature providers locales y PKCS#11 en el componente PDF de HotPDF para Delphi, así que la firma remota, por token y por archivo alimentan todos las mismas llamadas de firma PDF