Una factura firmada verifica byte a byte, su firmante fue revocado el mes pasado, y la CRL que usted descargó nunca lista el número de serie: habla por otra partición. La validación de rutas de HotPDF está diseñada en torno a casos así: reglas de alcance y de anchors de RFC 5280, un reloj de evaluación en UTC y sellos de tiempo RFC 3161 cuya hora cuenta solo cuando la cadena TSA es de confianza
El reloj de evaluación corre en UTC
Cada comparación de un validador de rutas está almacenada en UTC. RFC 5280 §4.1.2.5 exige que las fechas de validez de los certificados se codifiquen como UTCTime o GeneralizedTime en Greenwich Mean Time, las thisUpdate y nextUpdate de las CRL usan las mismas codificaciones, y las respuestas OCSP de 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 verificación escrita como if Now > NotAfter mezcla dos relojes sin ningún aviso del compilador. El error se parte por geografía: al oeste de Greenwich corre temprano. 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 queda cinco horas en el futuro del reloj ingenuo, lejos de una tolerancia de skew 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 14:00Z, un certificado de firma cuyo notAfter es 20:00Z ya parece vencido, ocho horas antes. Ninguno de los dos fallos aparece en un build agent configurado en UTC — el valor por defecto de la mayoría de los runners de CI alojados
HotPDF trata THPDFCertificateValidationOptions.ValidationTime como UTC. Dejada en su valor por defecto de cero, la hora de evaluación pasa a ser la hora UTC actual, salvo que un sello de tiempo confiable 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 todo esto y después construye la ruta. Cuando usted fija un momento histórico por su cuenta, digamos para un auditor que pregunta si la firma de una factura era válida al cierre del trimestre, la conversión es asunto suyo: TTimeZone.Local.ToUniversalTime de System.DateUtils lo hace. Aplíquela donde el sello de tiempo entra a su 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 horas locales no puede representar 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 como lo registró el ERP, en la zona local del servidor
ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
// Certificados, CRLs y respuestas OCSP llevan horas en 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 en realidad una CRL?
Una CRL habla únicamente por los certificados dentro de su alcance, y el alcance se declara en la extensión Issuing Distribution Point de 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 reason codes, como keyCompromiseonlyContainsAttributeCerts: la CRL va sobre attribute certificates 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, revisa thisUpdate y nextUpdate, busca el número de serie y da por bueno el certificado cuando el serial no aparece. Ese último paso solo se sostiene después de la prueba de alcance de 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 limitada 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 attribute certificates ni a ciertos reasons, su restricción de usuario o de CA coincide con el certificado, y todo distribution point que nombre coincide con una de las URIs de la extensión CRL Distribution Points propia del certificado, comparado sin distinguir mayúsculas — un punto dado como nombre relativo al emisor de la CRL no coincide con nada. Una CRL limitada por reasons no se consulta para nada, así que las revocaciones que lista tampoco cuentan. La §6.3.3 sí permite armar cobertura completa a partir de particiones por reason, siguiendo una máscara de reasons hasta cubrirlos todos, pero para firmas de documentos una respuesta OCSP o una CRL de alcance completo es evidencia más simple: recoja una de esas. Sin evidencia en alcance restante, el estado de revocación es desconocido y, bajo rvmRequire, la llamada devuelve cvsRevocationUnknown
Los trust anchors que cierran una ruta deben ser CA
RFC 5280 §6.1.1 le entrega al algoritmo de validación de rutas su trust anchor como información plana, un nombre de emisor y una clave pública, y el algoritmo nunca pregunta si el certificado propio del anchor puede emitir algo; la prueba de basicConstraints de la §6.1.4 corre sobre los certificados intermedios de la ruta. Eso está bien cuando los anchors son CA raíz de un almacén curado. Los flujos de documentos rompen esa suposición, porque la gente fija certificados de entidad final: un sistema de compras que recibe contratos de tres proveedores conocidos suele confiar directamente en el certificado de firma de cada proveedor. Fijar funciona para rutas de un solo certificado, donde firmante y anchor son el mismo certificado. El peligro es una ruta de dos certificados: la clave privada de un proveedor puede firmar un certificado fresco que lleve cualquier subject, el nombre de su director financiero, por ejemplo, y un validador que trate toda clave de anchor como clave emisora construye una ruta limpia desde esa falsificación hasta la hoja fijada
HotPDF aplica los requisitos de emisor de RFC 5280 al anchor siempre que la ruta sea más larga que un certificado. El anchor necesita entonces basicConstraints con cA=TRUE (§4.2.1.9) y, si lleva extensión keyUsage, el bit keyCertSign debe estar activo (§4.2.1.3). Una raíz sin extensión basicConstraints cuenta como emisora solo cuando es self-issued, con nombres de subject e issuer iguales, lo que mantiene funcionando las 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 como cvsPathValidationFailed, con una cadena Issue que indica que el anchor que emitió la ruta no es una autoridad certificadora
¿Qué sellos de tiempo pueden fijar la hora de evaluación?
Solo los confiables. Un token RFC 3161 incrustado en el CMS afirma que el valor de firma existía en el genTime, y los validadores juzgan el certificado del firmante en ese momento — así es como una firma sobrevive a su certificado. El token también es 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 vencida o revocada puede montar su propio servicio de sellado de tiempo, sellar una firma nueva con un genTime del año pasado y meterla de vuelta dentro del 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 critical. HotPDF adopta Info.Timestamp.GenerationTime como hora de evaluación solo después de que el certificado del token valide contra sus anchors, a la hora UTC actual, para el propósito de sellado de tiempo. Un sello de tiempo 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 revisa con sus propias opciones, la raíz de la TSA debe estar en la misma lista TrustAnchors que la raíz del firmante y, bajo rvmRequire, la cadena TSA necesita evidencia de revocación propia. Si falta cualquiera de las dos, la evaluación retrocede 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 cuando ese certificado vence, la evaluación a hora de firma se va con él, y los archivos añaden un sello de tiempo de documento antes de ese punto, el paso cubierto en evidencia LTV PAdES y renovación de sellos B-LTA. Un código que tenga los bytes crudos del CMS puede hacer la misma pregunta vía HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), y ValidateLoadedSignatureBatch la aplica a cada firma de un lote. La función de abajo reporta sobre qué reloj se tomó el veredicto
// Esperado: solo con SignerRoot, una firma cuyo certificado venció en 2025
// se evalúa a la hora UTC actual y no vuelve cvsTrusted, aunque su token
// de sello de tiempo verifique; con TsaRoot añadido, la misma llamada se
// evalúa en el 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 TSA se juzga contra la misma lista de anchors que el firmante
SetLength(Options.TrustAnchors, 2);
Options.TrustAnchors[0] := SignerRoot;
Options.TrustAnchors[1] := TsaRoot;
Options.RevocationMode := rvmBestEffort;
Options.ValidationTime := 0; // un sello de tiempo confiable 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 confiable. Los intermedios faltantes se descargan vía las URLs caIssuers de Authority Information Access de 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 recorre. En Delphi eso descarta un ciclo for: el valor final de una sentencia for se evalúa una vez, antes de que el ciclo arranque — las URLs añadidas durante el recorrido nunca se visitan, y una cadena a la que le faltan dos niveles queda incompleta. Vuelva a leer Count en cada pasada con un ciclo while en cambio. En HotPDF, MaxFetch sobre HPDFFetchAIAIntermediates acota cuántos certificados descarga un recorrido, y MaxFetchedIntermediates fija el mismo tope 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 fue nombrado por un certificado sin verificar, y puede pedir una pausa de una hora en cada petición. Acótele: THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS fija ese techo para las descargas de evidencia y AIA de HotPDF, 30,000 ms por defecto. La rutina de abajo aplica ambas reglas para equipos que recorren la AIA por su cuenta, digamos para precalentar el caché de intermedios de una capa de firma sin conexión
uses
System.SysUtils, System.Generics.Collections;
type
// Su transporte: descarga un objetivo caIssuers y devuelve las URLs
// caIssuers que anuncia el certificado recibido
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 ciclo for fijaría Queue.Count al entrar y nunca alcanzaría las
// URLs 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;
// La espera es un deseo del servidor, y un certificado sin verificar
// eligió ese servidor
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 verificación de firma del CMS en sí corre antes de la validación de rutas, porque construir una ruta para una firma que no verifica es trabajo desperdiciado, y usa la clave pública del certificado incrustado en la firma. Ese orden pone una clave de tamaño elegido por el atacante a cargo justo cuando corre la aritmética más costosa, y todavía no hay nada confiable. El costo 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 archivos subidos. HotPDF rechaza un módulo RSA mayor a 16,384 bits, rechaza un exponente más largo que el módulo y, por encima de 3,072 bits, acepta solo exponentes de a lo más 64 bits. Las claves de firma reales viven entre 2,048 y 4,096 bits con e = 65537, un exponente de 17 bits, así que el techo nunca las toca; el piso es una política separada y configurable, AlgorithmPolicy.MinimumRSAKeyBits, que por defecto vale 2,048
La misma firma puede estar cvsTrusted en el genTime de su sello de tiempo y fallar en la fecha de hoy. Registre 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é anchor descansa la respuesta