Uma fatura assinada verifica byte a byte, o signer dela foi revogado no mês passado, e a CRL que você baixou nunca lista o número de série: ela fala por outra partição. A validação de caminho no HotPDF é desenhada em volta de casos assim: regras de escopo e âncora do RFC 5280, um relógio de avaliação em UTC e timestamps RFC 3161 cuja hora só conta depois que a cadeia da TSA é confiável
O relógio de avaliação roda em UTC
Toda comparação que um validador de caminho faz fica armazenada em UTC. O RFC 5280 §4.1.2.5 exige que as datas de validade de certificado sejam codificadas como UTCTime ou GeneralizedTime no Greenwich Mean Time, CRLs usam as mesmas codificações em thisUpdate e nextUpdate, e respostas OCSP do RFC 6960 carregam producedAt, thisUpdate e nextUpdate como GeneralizedTime em UTC. O TDateTime do Delphi não tem fuso horário e o Now devolve hora de parede local, então uma checagem escrita como if Now > NotAfter mistura dois relógios sem aviso de compilador. O erro se divide por geografia. A oeste de Greenwich ele roda cedo. Num servidor de Nova York às 09:00 locais, quando o relógio mundial marca 14:00Z, uma resposta OCSP produzida às 13:59Z fica cinco horas no futuro do relógio ingênuo, muito fora de uma tolerância de skew de cinco minutos como o OCSPAllowedClockSkewMinutes default do HotPDF, e é recusada como ainda não válida. Em Xangai o sinal inverte: às 22:00 locais, que são 14:00Z, um certificado de assinatura cujo notAfter é 20:00Z já parece expirado, oito horas antes da hora. Nenhuma das falhas aparece em um build agent configurado em UTC — o default na maioria dos runners de CI hospedados
O HotPDF trata o THPDFCertificateValidationOptions.ValidationTime como UTC. Deixado no default dele, zero, o horário de avaliação vira a hora UTC corrente, a menos que um timestamp confiável forneça o horário de assinatura (mais sobre isso abaixo), e o instante de fato usado volta no Info.CertificateValidation.EvaluationTime. O VerifyLoadedSignatureWithTrust roda a verificação de byte range por trás do svValid antes de qualquer coisa isso, depois monta o caminho. Quando você fixa um momento histórico você mesmo, digamos para um auditor perguntando se a assinatura de uma fatura estava boa no fechamento do trimestre, a conversão é tarefa sua. O TTimeZone.Local.ToUniversalTime do System.DateUtils faz isso. Aplique-o onde o timestamp entra no sistema e não na chamada de validação: em fusos com daylight saving uma hora local se repete todo outono e outra nunca existe na primavera, então uma coluna de banco de dados de timestamps locais não consegue representar de volta todo instante
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;
// Fechamento do trimestre como o ERP o registrou, no fuso local do servidor
ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
// Certificados, CRLs e respostas OCSP carregam horários em 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;
Quais certificados uma CRL de fato cobre?
Uma CRL fala apenas pelos certificados dentro do escopo dela, e o escopo é declarado na extensão Issuing Distribution Point do RFC 5280 §5.2.5. Uma CA que particiona os dados de revogação publica várias CRLs, todas assinadas com a mesma chave e todas frescas, e a ausência de um certificado em uma delas não significa nada a menos que aquela CRL afirme cobri-lo. Os campos que estreitam o escopo são estes:
distributionPoint: a CRL cobre apenas certificados cuja extensão CRL Distribution Points nomeia esse pontoonlyContainsUserCertseonlyContainsCACerts: só certificados de entidade final, ou só certificados de CAonlySomeReasons: revogações para um subconjunto de reason codes, como keyCompromiseonlyContainsAttributeCerts: a CRL trata de attribute certificates e não diz nada sobre certificados de chave públicaindirectCRL: as entradas podem pertencer a issuers outros que não o signer da CRL
A busca ingênua verifica a assinatura da CRL, confere thisUpdate e nextUpdate, procura o número de série e declara o certificado bom quando o número não aparece. Esse último passo só vale depois do teste de escopo do RFC 5280 §6.3.3 (b)(2). A CRL da partição 7 nunca lista um certificado da partição 3. Uma CRL restrita a certificados de CA nunca lista um signer de entidade final. As duas respondem "não encontrado" para um certificado revogado. O HotPDF consulta uma CRL base para um certificado só quando a CRL não está limitada nem a attribute certificates nem a alguns reasons, a restrição de user ou CA dela bate com o certificado, e qualquer distribution point que ela nomeia bate com um dos URIs na extensão CRL Distribution Points do próprio certificado, comparados sem distinção de maiúsculas — um ponto dado como nome relativo ao issuer da CRL não bate com nada. Uma CRL limitada por reasons não é consultada de forma alguma, então as revogações que ela lista também não contam. §6.3.3 até permite montar cobertura completa a partir de partições de reasons, acompanhando uma máscara de reasons até todo reason estar coberto, mas para assinaturas de documentos uma resposta OCSP ou uma CRL de escopo completo é evidência mais simples: colete uma dessas. Sem evidência em escopo restante, o status de revogação é unknown, e sob rvmRequire a chamada retorna cvsRevocationUnknown
Trust anchors que fecham um caminho precisam ser CAs
O RFC 5280 §6.1.1 entrega ao algoritmo de validação de caminho a trust anchor dele como informação pura, um nome de issuer e uma chave pública, e o algoritmo nunca pergunta se o certificado da própria âncora pode emitir algo; o teste de basicConstraints em §6.1.4 roda sobre os certificados intermediários do caminho. Isso funciona quando as âncoras são root CAs de uma store curada. Fluxos de documento quebram a premissa, porque as pessoas fixam certificados de entidade final: um sistema de compras que recebe contratos de três fornecedores conhecidos frequentemente confia direto no certificado de assinatura de cada fornecedor. Fixar funciona para um caminho de um certificado, onde signer e âncora são o mesmo certificado. O perigo é um caminho de dois certificados. A chave privada de um fornecedor pode assinar um certificado fresco carregando qualquer subject, o nome do seu CFO por exemplo, e um validador que trata toda chave de âncora como chave de emissão monta um caminho limpo dessa falsificação até a folha fixada
O HotPDF aplica os requisitos de issuer do RFC 5280 à âncora sempre que o caminho é mais longo que um certificado. A âncora então precisa de basicConstraints com cA=TRUE (§4.2.1.9), e se ela carrega uma extensão keyUsage, o bit keyCertSign precisa estar definido (§4.2.1.3). Uma root sem extensão basicConstraints conta como issuer só quando é self-issued, com subject e issuer iguais, o que mantém funcionando as roots antigas da versão 1 que antecedem as extensões. Fixar continua funcionando. Um certificado de entidade final fixado ainda valida as assinaturas dele próprias, enquanto um certificado que ele assinou volta cvsPathValidationFailed, com uma string Issue afirmando que a âncora que emitiu o caminho não é uma certificate authority
Quais timestamps podem definir o horário de avaliação?
Só os confiáveis. Um token RFC 3161 embutido no CMS afirma que o valor da assinatura existia no genTime, e validadores julgam o certificado do signer naquele momento — é assim que uma assinatura sobrevive ao certificado dela. O token também é só mais uma assinatura CMS. Verificá-lo contra o certificado que ele carrega prova consistência interna e nada sobre quem o produziu: qualquer um segurando uma chave de assinatura expirada ou revogada pode rodar o próprio serviço de time-stamping, carimbar uma assinatura nova com o genTime do ano passado e empurrá-la de volta para dentro do período de validade do certificado. O RFC 3161 §2.3 exige que o certificado da TSA carregue o extended key usage id-kp-timeStamping como único propósito, marcado critical. O HotPDF adota o Info.Timestamp.GenerationTime como horário de avaliação só depois que o certificado do token valida contra as suas âncoras, na hora UTC corrente, para o propósito de time-stamping. Um document timestamp, /SubFilter ETSI.RFC3161, é um caso diferente. Ali o token é a própria assinatura sob validação, e o genTime dele se aplica ao caminho dele próprio
Como o token é conferido com as suas próprias opções, a root da TSA precisa estar na mesma lista de TrustAnchors que a root do signer, e sob rvmRequire a cadeia da TSA precisa de evidência de revogação própria. Perder qualquer um dos dois e a avaliação recai para a hora corrente. O tempo de vida do certificado da TSA é o outro limite: o token é julgado na hora corrente, então quando esse certificado expira a avaliação no horário de assinatura vai embora com ele, e arquivos acrescentam um document timestamp antes desse ponto, a etapa coberta em evidência PAdES LTV e renovação de timestamps B-LTA. Código que segura bytes CMS puros pode fazer a mesma pergunta via HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), e o ValidateLoadedSignatureBatch aplica isso a toda assinatura de um lote. A função abaixo reporta em qual relógio um veredito foi tomado
// Esperado: com apenas o SignerRoot, uma assinatura cujo certificado expirou
// em 2025 é avaliada na hora UTC corrente e não volta
// cvsTrusted, embora o token de timestamp dela verifique; com o TsaRoot acrescentado,
// a mesma chamada é avaliada no 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;
// A cadeia da TSA é julgada contra a mesma lista de âncoras que o signer
SetLength(Options.TrustAnchors, 2);
Options.TrustAnchors[0] := SignerRoot;
Options.TrustAnchors[1] := TsaRoot;
Options.RevocationMode := rvmBestEffort;
Options.ValidationTime := 0; // um timestamp confiável pode fornecer a 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 da primeira decisão de confiança
Parte do trabalho de validação roda sobre dados que um atacante controla por completo, antes de qualquer coisa ter sido confiável. Intermediários ausentes são buscados pelas URLs caIssuers de Authority Information Access do RFC 5280 §4.2.2.1, e cada intermediário buscado pode anunciar por sua vez a URL do issuer dele, então a worklist cresce enquanto você a percorre. No Delphi isso elimina um loop for. O valor final de um for statement é avaliado uma vez, antes do loop começar — URLs anexadas durante o caminho nunca são visitadas, e uma cadeia faltando dois níveis fica incompleta. Releia o Count a cada passada com um loop while em vez disso. No HotPDF, o MaxFetch do HPDFFetchAIAIntermediates limita quantos certificados uma passada baixa, e o MaxFetchedIntermediates define o mesmo limite quando o FetchMissingIntermediates deixa a validação de caminho buscar por conta própria
A dica Retry-After é outro valor que um estranho escolhe. O servidor dela foi nomeado por um certificado não verificado, e ele pode pedir uma hora de pausa em cada request. Fixe um teto. O THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS define esse teto para as buscas de evidência e AIA do HotPDF, 30.000 ms por default. A rotina abaixo aplica as duas regras para times que rastejam a AIA por conta própria, digamos para pré-aquecer o cache de intermediários de uma camada de assinatura offline
uses
System.SysUtils, System.Generics.Collections;
type
// Seu transporte: baixa um alvo caIssuers e devolve as
// URLs caIssuers anunciadas pelo certificado que ele recebeu
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;
// Um for loop fixaria o Queue.Count na entrada e jamais alcançaria as
// URLs que os certificados buscados anexam abaixo
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;
// A espera é desejo do servidor, e um certificado não verificado
// escolheu o 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;
A checagem de assinatura CMS em si roda antes da validação de caminho, porque construir um caminho para uma assinatura que não verifica é trabalho desperdiçado, e ela usa a chave pública do certificado embutido na assinatura. Essa ordem põe um tamanho de chave escolhido pelo atacante no comando no momento em que a aritmética mais cara roda. Nada é confiável ainda. O custo da verificação RSA cresce com o comprimento do modulus e o do exponent, então um certificado com um modulus enorme e um exponent igualmente longo pode manter uma thread ocupada em exponenciação modular num servidor que verifica arquivos enviados. O HotPDF recusa um modulus RSA acima de 16.384 bits, recusa um exponent mais longo que o modulus, e acima de 3.072 bits aceita apenas exponents de no máximo 64 bits. Chaves de assinatura reais ficam entre 2.048 e 4.096 bits com e = 65537, um exponent de 17 bits, então o teto nunca as toca; o piso é uma política separada e configurável, o AlgorithmPolicy.MinimumRSAKeyBits, cujo default é 2.048
A mesma assinatura pode estar cvsTrusted no genTime do timestamp dela e falhar na data de hoje. Registre Info.CertificateValidation.EvaluationTime e TrustAnchorSubjectDN ao lado de todo veredito que o componente Delphi PDF HotPDF retorna, e um auditor lendo o registro depois vê em qual relógio e em qual âncora a resposta se apoia