Una factura firmada verifica byte a byte, su firmante fue revocado el mes pasado y la CRL que descargaste nunca lista ese número de serie: habla por otra partición. La validación de rutas de HotPDF está pensada para casos así: reglas de alcance y anclas del RFC 5280, un reloj de evaluación en UTC y timestamps RFC 3161 cuya hora solo cuenta cuando la cadena de la TSA es de confianza
El reloj de evaluación funciona en UTC
Cada comparación que hace un validador de rutas está almacenada en UTC. El RFC 5280 §4.1.2.5 exige que las fechas de validez de los certificados se codifiquen como UTCTime o GeneralizedTime en hora del meridiano de Greenwich, los thisUpdate y nextUpdate de las CRL usan las mismas codificaciones, y las respuestas OCSP del RFC 6960 llevan producedAt, thisUpdate y nextUpdate como GeneralizedTime en UTC. El TDateTime de Delphi no tiene zona horaria y Now devuelve la hora local de pared, así que una comprobación escrita como if Now > NotAfter mezcla dos relojes sin que el compilador diga nada. El error se reparte por geografía. Al oeste de Greenwich corre adelantado. En un servidor de Nueva York a las 09:00 locales, cuando el reloj mundial marca 14:00Z, una respuesta OCSP producida a las 13:59Z está cinco horas en el futuro del reloj ingenuo, muy fuera de una tolerancia de cinco minutos como el OCSPAllowedClockSkewMinutes por defecto de HotPDF, y se rechaza como aún no válida. En Shanghái el signo se invierte: a las 22:00 locales, que son las 14:00Z, un certificado de firma con notAfter a las 20:00Z ya parece caducado, ocho horas antes de tiempo. Ninguno de los dos fallos aparece en un agente de build en UTC — el valor por defecto en la mayoría de los runners de CI alojados
HotPDF trata THPDFCertificateValidationOptions.ValidationTime como UTC. Si se deja a su valor por defecto de cero, la hora de evaluación pasa a ser la UTC actual, salvo que un timestamp de confianza aporte la hora de firma (más sobre eso abajo), y el instante realmente usado vuelve en Info.CertificateValidation.EvaluationTime. VerifyLoadedSignatureWithTrust ejecuta la verificación de byte-range detrás de svValid antes de nada de esto y después construye la ruta. Cuando fijas tú un momento histórico, por ejemplo para un auditor que pregunta si la firma de una factura era válida al cierre de trimestre, la conversión es cosa tuya: TTimeZone.Local.ToUniversalTime de System.DateUtils lo hace. Aplícala donde el timestamp entra en tu sistema y no en la llamada de validación: en zonas con horario de verano una hora local se repite cada otoño y otra nunca existe en primavera, así que una columna de base de datos con timestamps locales no puede hacer round-trip de todos los instantes
uses
System.SysUtils, System.DateUtils, System.IOUtils,
HPDFDoc, HPDFSignVerify;
var
Pdf: THotPDF;
Options: THPDFCertificateValidationOptions;
Info: THPDFSignatureInfo;
Status: THPDFCertificateValidationStatus;
ClosingLocal: TDateTime;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('invoices-2026-q1.pdf');
Options := THPDFCertificateValidationOptions.Default;
SetLength(Options.TrustAnchors, 1);
Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
Options.RevocationMode := rvmRequire;
// Cierre de trimestre tal como lo anotó el ERP, en la zona local del servidor
ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
// Certificados, CRLs y respuestas OCSP llevan todas horas UTC
Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
Writeln('Evaluated at (UTC): ',
DateTimeToStr(Info.CertificateValidation.EvaluationTime));
case Status of
cvsTrusted:
Writeln('Trusted via ',
string(Info.CertificateValidation.TrustAnchorSubjectDN));
cvsRevoked:
Writeln('Revoked at (UTC): ',
DateTimeToStr(Info.CertificateValidation.RevokedAt));
else
Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
end;
finally
Pdf.Free;
end;
end;
¿Qué certificados cubre de verdad una CRL?
Una CRL habla solo de los certificados dentro de su alcance, y el alcance se declara en la extensión Issuing Distribution Point del RFC 5280 §5.2.5. Una CA que particiona sus datos de revocación publica varias CRL, todas firmadas con la misma clave y todas frescas, y la ausencia de un certificado en una de ellas no significa nada salvo que esa CRL declare cubrirlo. Los campos que estrechan el alcance son estos:
distributionPoint: la CRL cubre solo los certificados cuya extensión CRL Distribution Points nombra este puntoonlyContainsUserCertsyonlyContainsCACerts: solo certificados de entidad final, o solo certificados de CAonlySomeReasons: revocaciones para un subconjunto de códigos de motivo, como keyCompromiseonlyContainsAttributeCerts: la CRL va de certificados de atributos y no dice nada sobre certificados de clave públicaindirectCRL: las entradas pueden pertenecer a emisores distintos del firmante de la CRL
La búsqueda ingenua verifica la firma de la CRL, comprueba thisUpdate y nextUpdate, busca el número de serie y da el certificado por bueno cuando el número no aparece. Ese último paso solo se sostiene después de la prueba de alcance del RFC 5280 §6.3.3 (b)(2). La CRL de la partición 7 nunca lista un certificado de la partición 3. Una CRL restringida a certificados de CA nunca lista un firmante de entidad final. Ambas responden "no encontrado" para un certificado revocado. HotPDF consulta una CRL base para un certificado solo cuando la CRL no está limitada ni a certificados de atributos ni a algunos motivos, su restricción de usuario o CA coincide con el certificado, y cualquier distribution point que nombre coincide con una de las URI de la propia extensión CRL Distribution Points del certificado, comparadas sin distinguir mayúsculas — un punto dado como nombre relativo al emisor de la CRL no coincide con nada. Una CRL limitada por motivos no se consulta en absoluto, así que las revocaciones que lista tampoco cuentan. §6.3.3 permite armar cobertura completa a partir de particiones por motivo, siguiendo una máscara de motivos hasta dar cuenta de todos, pero para firmas de documentos una respuesta OCSP o una CRL de alcance completo son evidencia más simple: recoge una de esas. Sin evidencia en alcance restante, el estado de revocación es desconocido, y con rvmRequire la llamada devuelve cvsRevocationUnknown
Las anclas de confianza que cierran una ruta deben ser CA
El RFC 5280 §6.1.1 le entrega al algoritmo de validación de rutas su ancla de confianza como información plana, un nombre de emisor y una clave pública, y el algoritmo nunca pregunta si el certificado de la ancla puede emitir algo; la prueba de basicConstraints de §6.1.4 corre sobre los certificados intermedios de la ruta. Eso va bien cuando las anclas son CA raíz de un almacén curado. Los flujos de documentos rompen el supuesto, porque la gente fija certificados de entidad final: un sistema de contratación que recibe contratos de tres proveedores conocidos suele confiar directamente en el certificado de firma de cada proveedor. Fijar funciona para una ruta de un certificado, donde firmante y ancla son el mismo certificado. El peligro es la ruta de dos certificados. La clave privada de un proveedor puede firmar un certificado fresco con el sujeto que sea, el nombre de tu director financiero por ejemplo, y un validador que trate cada clave de ancla como clave emisora construye una ruta limpia desde esa falsificación hasta la hoja fijada
HotPDF aplica los requisitos de emisor del RFC 5280 a la ancla siempre que la ruta tenga más de un certificado. La ancla necesita entonces basicConstraints con cA=TRUE (§4.2.1.9) y, si lleva extensión keyUsage, el bit keyCertSign tiene que estar activo (§4.2.1.3). Una raíz sin extensión basicConstraints cuenta como emisora solo cuando es autoemitida, con sujeto y emisor iguales, lo que mantiene funcionando las viejas raíces de versión 1 anteriores a las extensiones. Fijar sigue funcionando: un certificado de entidad final fijado sigue validando sus propias firmas, mientras que un certificado que él firmó vuelve con cvsPathValidationFailed, con una cadena Issue que dice que la ancla que emitió la ruta no es una autoridad de certificación
¿Qué timestamps pueden fijar la hora de evaluación?
Solo los de confianza. Un token RFC 3161 incrustado en el CMS afirma que el valor de la firma existía en genTime, y los validadores juzgan el certificado del firmante en ese momento — así es como una firma sobrevive a su certificado. El token es además solo otra firma CMS. Verificarlo contra el certificado que lleva prueba consistencia interna y nada sobre quién lo produjo: cualquiera con una clave de firma caducada o revocada puede montar su propio servicio de sellado de tiempo, sellar una firma nueva con el genTime del año pasado y colarla de vuelta en el periodo de validez del certificado. RFC 3161 §2.3 exige que el certificado de la TSA lleve el extended key usage id-kp-timeStamping como único propósito, marcado como critical. HotPDF adopta Info.Timestamp.GenerationTime como hora de evaluación solo después de que el certificado del token valide contra tus anclas, a la hora UTC actual, para el propósito de sellado de tiempo. Un timestamp de documento, /SubFilter ETSI.RFC3161, es otro caso: ahí el token es la firma bajo validación y su genTime aplica a su propia ruta
Como el token se comprueba con tus propias opciones, la raíz de la TSA tiene que estar en la misma lista TrustAnchors que la raíz del firmante, y con rvmRequire la cadena de la TSA necesita evidencia de revocación propia. Si falta cualquiera de las dos cosas, la evaluación vuelve a la hora actual. El tiempo de vida del certificado de la TSA es el otro límite: el token se juzga a la hora actual, así que en cuanto ese certificado caduca se va con él la evaluación a hora de firma, y los archivos añaden un timestamp de documento antes de ese punto, el paso cubierto en evidencia LTV PAdES y renovación de timestamps B-LTA. El código que tiene los bytes CMS en crudo puede hacer la misma pregunta mediante HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), y ValidateLoadedSignatureBatch la aplica a cada firma de un lote. La función de abajo reporta sobre qué reloj se tomó un veredicto
// Esperado: solo con SignerRoot, una firma cuyo certificado caducó en 2025
// se evalúa a la hora UTC actual y no vuelve como
// cvsTrusted, aunque su token de timestamp verifica; con TsaRoot añadida,
// la misma llamada se evalúa a genTime
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
const SignerRoot, TsaRoot: TBytes): string;
var
Options: THPDFCertificateValidationOptions;
Info: THPDFSignatureInfo;
Status: THPDFCertificateValidationStatus;
Clock: string;
begin
Options := THPDFCertificateValidationOptions.Default;
// La cadena de la TSA se juzga contra la misma lista de anclas que el firmante
SetLength(Options.TrustAnchors, 2);
Options.TrustAnchors[0] := SignerRoot;
Options.TrustAnchors[1] := TsaRoot;
Options.RevocationMode := rvmBestEffort;
Options.ValidationTime := 0; // un timestamp de confianza puede aportar la hora
Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
if Info.Timestamp.Kind <> tskSignature then
Clock := 'no signature timestamp, current UTC time'
else if (Info.Timestamp.Status = svValid) and
(Info.CertificateValidation.EvaluationTime =
Info.Timestamp.GenerationTime) then
Clock := 'trusted timestamp genTime'
else
Clock := 'timestamp not trusted, current UTC time';
Result := Format('%s at %s (UTC), status %d', [Clock,
DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;
Entrada hostil antes de la primera decisión de confianza
Parte del trabajo de validación corre sobre datos que un atacante controla por completo, antes de que nada haya sido declarado de confianza. Los intermedios que faltan se descargan por las URL caIssuers de Authority Information Access del RFC 5280 §4.2.2.1, y cada intermedio descargado puede anunciar a su vez la URL de su propio emisor, así que la lista de trabajo crece mientras la recorres. En Delphi eso descarta un bucle for: el valor final de una sentencia for se evalúa una vez, antes de que el bucle empiece — las URL añadidas durante el recorrido nunca se visitan y una cadena a la que le faltan dos niveles se queda incompleta. Relee Count en cada pasada con un bucle while en su lugar. En HotPDF, MaxFetch en HPDFFetchAIAIntermediates acota cuántos certificados descarga un recorrido, y MaxFetchedIntermediates pone el mismo techo cuando FetchMissingIntermediates deja que la validación de rutas descargue por su cuenta
La pista Retry-After es otro valor que elige un desconocido. Su servidor lo nombró un certificado sin verificar, y puede pedir una pausa de una hora en cada petición. Acótalo: THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS fija ese techo para las descargas de evidencia y AIA de HotPDF, 30.000 ms por defecto. La rutina de abajo aplica las dos reglas para equipos que rastrean AIA por su cuenta, por ejemplo para precalentar la caché de intermedios de un nivel de firma sin conexión
uses
System.SysUtils, System.Generics.Collections;
type
// Tu transporte: descarga un objetivo caIssuers y devuelve las
// URL caIssuers que anuncia el certificado que recibió
TFetchIssuer = reference to function(const URL: string;
out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;
function WalkCAIssuers(const SeedURLs: TArray<string>;
const Fetch: TFetchIssuer; MaxFetch: Integer;
MaxRetryAfterMS: Cardinal): Integer;
var
Queue: TList<string>;
Seen: TDictionary<string, Boolean>;
NextURLs: TArray<string>;
URL: string;
I, Attempt: Integer;
RetryAfter: Cardinal;
Ok: Boolean;
procedure Enqueue(const AURL: string);
begin
if not Seen.ContainsKey(LowerCase(AURL)) then
begin
Seen.Add(LowerCase(AURL), True);
Queue.Add(AURL);
end;
end;
begin
Result := 0;
Queue := TList<string>.Create;
Seen := TDictionary<string, Boolean>.Create;
try
for URL in SeedURLs do
Enqueue(URL);
I := 0;
// Un bucle for fijaría Queue.Count al entrar y nunca llegaría a las
// URL que los certificados descargados añaden abajo
while (I < Queue.Count) and (Result < MaxFetch) do
begin
Ok := False;
for Attempt := 1 to 3 do
begin
NextURLs := nil;
RetryAfter := 0;
Ok := Fetch(Queue[I], NextURLs, RetryAfter);
if Ok or (Attempt = 3) then
Break;
// El retardo lo desea el servidor, y quien lo eligió fue
// un certificado sin verificar
if RetryAfter > MaxRetryAfterMS then
RetryAfter := MaxRetryAfterMS;
Sleep(RetryAfter);
end;
Inc(I);
Inc(Result);
if Ok then
for URL in NextURLs do
Enqueue(URL);
end;
finally
Seen.Free;
Queue.Free;
end;
end;
La propia comprobación de firma CMS corre antes de la validación de rutas, porque construir una ruta para una firma que no verifica es trabajo tirado, y usa la clave pública del certificado incrustado en la firma. Ese orden pone el tamaño de clave elegido por el atacante al mando justo cuando corre la aritmética más cara. Nada es aún de confianza. El coste de la verificación RSA crece con la longitud del módulo y la del exponente, así que un certificado con un módulo enorme y un exponente igual de largo puede tener un hilo ocupado en exponenciación modular en un servidor que verifica ficheros subidos. HotPDF rechaza un módulo RSA por encima de 16.384 bits, rechaza un exponente más largo que el módulo y, por encima de 3.072 bits, acepta solo exponentes de 64 bits como máximo. Las claves de firma reales van de 2.048 a 4.096 bits con e = 65537, un exponente de 17 bits, así que el techo nunca las toca; el suelo es una política aparte y configurable, AlgorithmPolicy.MinimumRSAKeyBits, que por defecto vale 2.048
La misma firma puede estar cvsTrusted al genTime de su timestamp y fallar en la fecha de hoy. Registra Info.CertificateValidation.EvaluationTime y TrustAnchorSubjectDN junto a cada veredicto que devuelva el componente PDF HotPDF para Delphi, y un auditor que lea el registro después podrá ver sobre qué reloj y qué ancla descansa la respuesta